From 6fa934880e54316bafd74a2c97843d91d73afd64 Mon Sep 17 00:00:00 2001 From: Knut Ahlers Date: Sun, 4 Feb 2018 14:51:08 +0100 Subject: [PATCH] Implement Crowd authentication (#2) * Re-add example configuration for Crowd * Implement Crowd authentication * Fix: Some errors just mean there is no user * Document crowd provider * Vendor new dependencies * Reduce error messages: Check for config details --- Gopkg.lock | 8 +- README.md | 14 ++ auth_crowd.go | 178 ++++++++++++++ config.yaml | 7 + vendor/github.com/jda/go-crowd/LICENSE | 21 ++ vendor/github.com/jda/go-crowd/README.md | 23 ++ vendor/github.com/jda/go-crowd/auth.go | 70 ++++++ vendor/github.com/jda/go-crowd/auth_test.go | 34 +++ vendor/github.com/jda/go-crowd/base.go | 41 ++++ vendor/github.com/jda/go-crowd/base_test.go | 40 ++++ vendor/github.com/jda/go-crowd/cookie.go | 50 ++++ vendor/github.com/jda/go-crowd/cookie_test.go | 19 ++ .../jda/go-crowd/crowdauth/loginTemplate.go | 15 ++ .../jda/go-crowd/crowdauth/middleware.go | 205 ++++++++++++++++ vendor/github.com/jda/go-crowd/error.go | 9 + vendor/github.com/jda/go-crowd/groups.go | 87 +++++++ vendor/github.com/jda/go-crowd/groups_test.go | 62 +++++ vendor/github.com/jda/go-crowd/sso.go | 221 ++++++++++++++++++ vendor/github.com/jda/go-crowd/sso_test.go | 75 ++++++ vendor/github.com/jda/go-crowd/user.go | 64 +++++ vendor/github.com/jda/go-crowd/user_test.go | 32 +++ 21 files changed, 1274 insertions(+), 1 deletion(-) create mode 100644 auth_crowd.go create mode 100644 vendor/github.com/jda/go-crowd/LICENSE create mode 100644 vendor/github.com/jda/go-crowd/README.md create mode 100644 vendor/github.com/jda/go-crowd/auth.go create mode 100644 vendor/github.com/jda/go-crowd/auth_test.go create mode 100644 vendor/github.com/jda/go-crowd/base.go create mode 100644 vendor/github.com/jda/go-crowd/base_test.go create mode 100644 vendor/github.com/jda/go-crowd/cookie.go create mode 100644 vendor/github.com/jda/go-crowd/cookie_test.go create mode 100644 vendor/github.com/jda/go-crowd/crowdauth/loginTemplate.go create mode 100644 vendor/github.com/jda/go-crowd/crowdauth/middleware.go create mode 100644 vendor/github.com/jda/go-crowd/error.go create mode 100644 vendor/github.com/jda/go-crowd/groups.go create mode 100644 vendor/github.com/jda/go-crowd/groups_test.go create mode 100644 vendor/github.com/jda/go-crowd/sso.go create mode 100644 vendor/github.com/jda/go-crowd/sso_test.go create mode 100644 vendor/github.com/jda/go-crowd/user.go create mode 100644 vendor/github.com/jda/go-crowd/user_test.go diff --git a/Gopkg.lock b/Gopkg.lock index 96bba98..a745dad 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -43,6 +43,12 @@ revision = "ca9ada44574153444b00d3fd9c8559e4cc95f896" version = "v1.1" +[[projects]] + branch = "master" + name = "github.com/jda/go-crowd" + packages = ["."] + revision = "415c27e65cd496563601465cfe630d4b3245d70f" + [[projects]] branch = "master" name = "github.com/juju/errors" @@ -95,6 +101,6 @@ [solve-meta] analyzer-name = "dep" analyzer-version = 1 - inputs-digest = "8d56acebac43d560504fd8420c68a9021e29a60ee6b09d477ddc0d2e815a5606" + inputs-digest = "c60c92a35a0972af226bbe9e4e3638d032a0ad26646fa6ac68919f2b3b805e82" solver-name = "gps-cdcl" solver-version = 1 diff --git a/README.md b/README.md index 964803e..521faf2 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,20 @@ Each `rules` entry has two mandantory and three optional fields of which at leas The `allow` and `deny` directives are arrays of users and groups. Groups are prefixed using an `@` sign. There is a simple logic: Users before groups, denies before allows. So if you allow the group `@test` containing the user `mike` but deny the user `mike`, mike will not be able to access the matching sites. +### Provider configuration: Atlassian Crowd (`crowd`) + +The crowd auth provider connects nginx-sso with an Atlassian Crowd directory server. The SSO authentication cookie used by Jira and Confluence is also used by nginx-sso which means a login in Jira will also perform a login on nginx-sso and vice versa. + +```yaml +providers: + crowd: + url: "https://crowd.example.com/crowd/" + app_name: "" + app_pass: "" +``` + +The configuration is quite simple: Create an application in Crowd, enter the Crowd URL and the application credentials into the config and you're done. + ### Provider configuration: Simple Auth (`simple`) The simple auth provider consists of a static mapping between users and passwords and groups and users. This can be seen as the replacement of htpasswd files. diff --git a/auth_crowd.go b/auth_crowd.go new file mode 100644 index 0000000..213354a --- /dev/null +++ b/auth_crowd.go @@ -0,0 +1,178 @@ +package main + +import ( + "net/http" + "strings" + + crowd "github.com/jda/go-crowd" + log "github.com/sirupsen/logrus" + yaml "gopkg.in/yaml.v2" +) + +func init() { + registerAuthenticator(&authCrowd{}) +} + +type authCrowd struct { + URL string `yaml:"url"` + AppName string `yaml:"app_name"` + AppPassword string `yaml:"app_pass"` + + crowd crowd.Crowd +} + +// AuthenticatorID needs to return an unique string to identify +// this special authenticator +func (a authCrowd) AuthenticatorID() string { return "crowd" } + +// Configure loads the configuration for the Authenticator from the +// global config.yaml file which is passed as a byte-slice. +// If no configuration for the Authenticator is supplied the function +// needs to return the errAuthenticatorUnconfigured +func (a *authCrowd) Configure(yamlSource []byte) error { + envelope := struct { + Providers struct { + Crowd *authCrowd `yaml:"crowd"` + } `yaml:"providers"` + }{} + + if err := yaml.Unmarshal(yamlSource, &envelope); err != nil { + return err + } + + if envelope.Providers.Crowd == nil { + return errAuthenticatorUnconfigured + } + + a.URL = envelope.Providers.Crowd.URL + a.AppName = envelope.Providers.Crowd.AppName + a.AppPassword = envelope.Providers.Crowd.AppPassword + + if a.AppName == "" || a.AppPassword == "" { + return errAuthenticatorUnconfigured + } + + var err error + a.crowd, err = crowd.New(a.AppName, a.AppPassword, a.URL) + + return err +} + +// DetectUser is used to detect a user without a login form from +// a cookie, header or other methods +// If no user was detected the errNoValidUserFound needs to be +// returned +func (a authCrowd) DetectUser(res http.ResponseWriter, r *http.Request) (string, []string, error) { + cc, err := a.crowd.GetCookieConfig() + if err != nil { + return "", nil, err + } + + cookie, err := r.Cookie(cc.Name) + switch err { + case nil: + // Fine, we do have a cookie + case http.ErrNoCookie: + // Also fine, there is no cookie + return "", nil, errNoValidUserFound + default: + return "", nil, err + } + + ssoToken := cookie.Value + sess, err := a.crowd.GetSession(ssoToken) + if err != nil { + log.WithError(err).Debug("Getting crowd session failed") + return "", nil, errNoValidUserFound + } + + user := sess.User.UserName + cGroups, err := a.crowd.GetDirectGroups(user) + if err != nil { + return "", nil, err + } + + groups := []string{} + for _, g := range cGroups { + groups = append(groups, g.Name) + } + + return user, groups, nil +} + +// Login is called when the user submits the login form and needs +// to authenticate the user or throw an error. If the user has +// successfully logged in the persistent cookie should be written +// in order to use DetectUser for the next login. +// If the user did not login correctly the errNoValidUserFound +// needs to be returned +func (a authCrowd) Login(res http.ResponseWriter, r *http.Request) error { + username := r.FormValue(strings.Join([]string{a.AuthenticatorID(), "username"}, "-")) + password := r.FormValue(strings.Join([]string{a.AuthenticatorID(), "password"}, "-")) + + cc, err := a.crowd.GetCookieConfig() + if err != nil { + return err + } + + sess, err := a.crowd.NewSession(username, password, r.RemoteAddr) + if err != nil { + log.WithFields(log.Fields{ + "username": username, + }).WithError(err).Debug("Crowd authentication failed") + return errNoValidUserFound + } + + http.SetCookie(res, &http.Cookie{ + Name: cc.Name, + Value: sess.Token, + Path: "/", + Domain: cc.Domain, + Expires: sess.Expires, + Secure: cc.Secure, + HttpOnly: true, + }) + + return nil +} + +// LoginFields needs to return the fields required for this login +// method. If no login using this method is possible the function +// needs to return nil. +func (a authCrowd) LoginFields() (fields []loginField) { + return []loginField{ + { + Label: "Username", + Name: "username", + Placeholder: "Username", + Type: "text", + }, + { + Label: "Password", + Name: "password", + Placeholder: "****", + Type: "password", + }, + } +} + +// Logout is called when the user visits the logout endpoint and +// needs to destroy any persistent stored cookies +func (a authCrowd) Logout(res http.ResponseWriter, r *http.Request) (err error) { + cc, err := a.crowd.GetCookieConfig() + if err != nil { + return err + } + + http.SetCookie(res, &http.Cookie{ + Name: cc.Name, + Value: "", + Path: "/", + Domain: cc.Domain, + MaxAge: -1, + Secure: cc.Secure, + HttpOnly: true, + }) + + return nil +} diff --git a/config.yaml b/config.yaml index 74fc9ea..e7b2b90 100644 --- a/config.yaml +++ b/config.yaml @@ -29,6 +29,13 @@ acl: allow: ["luzifer", "@admins"] providers: + # Authentication against an Atlassian Crowd directory server + # Supports: Users, Groups + crowd: + url: "https://crowd.example.com/crowd/" + app_name: "" + app_pass: "" + # Authentication against embedded user database # Supports: Users, Groups simple: diff --git a/vendor/github.com/jda/go-crowd/LICENSE b/vendor/github.com/jda/go-crowd/LICENSE new file mode 100644 index 0000000..6a4d0e0 --- /dev/null +++ b/vendor/github.com/jda/go-crowd/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2015 Jonathan Auer + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/vendor/github.com/jda/go-crowd/README.md b/vendor/github.com/jda/go-crowd/README.md new file mode 100644 index 0000000..6ea1f0a --- /dev/null +++ b/vendor/github.com/jda/go-crowd/README.md @@ -0,0 +1,23 @@ +go-crowd +======== +Go library for interacting with [Atlassian Crowd](https://www.atlassian.com/software/crowd/) + +* [![GoDoc](https://godoc.org/github.com/jda/go-crowd?status.png)](http://godoc.org/github.com/jda/go-crowd) +* Crowd [API Documentation](https://developer.atlassian.com/display/CROWDDEV/Remote+API+Reference) + +## Client example +```go +client, err := crowd.New("crowd_app_user", + "crowd_app_password", + "crowd service URL") + +user, err := client.Authenticate("testuser", "testpass") +if err != nil { + /* + failure or reject from crowd. check if err = reason from + https://developer.atlassian.com/display/CROWDDEV/Using+the+Crowd+REST+APIs#UsingtheCrowdRESTAPIs-HTTPResponseCodesandErrorResponses + */ +} + +// if auth successful, user contains user information +``` \ No newline at end of file diff --git a/vendor/github.com/jda/go-crowd/auth.go b/vendor/github.com/jda/go-crowd/auth.go new file mode 100644 index 0000000..2336a4b --- /dev/null +++ b/vendor/github.com/jda/go-crowd/auth.go @@ -0,0 +1,70 @@ +package crowd + +import ( + "bytes" + "encoding/xml" + "fmt" + "io/ioutil" + "net/http" + "net/url" +) + +type authReq struct { + XMLName struct{} `xml:"password"` + Password string `xml:"value"` +} + +// Authenticate a user & password against Crowd. Returns error on failure +// or account lockout. Success is a populated User with nil error. +func (c *Crowd) Authenticate(user string, pass string) (User, error) { + u := User{} + + ar := authReq{Password: pass} + arEncoded, err := xml.Marshal(ar) + if err != nil { + return u, err + } + arBuf := bytes.NewBuffer(arEncoded) + + v := url.Values{} + v.Set("username", user) + url := c.url + "rest/usermanagement/1/authentication?" + v.Encode() + + client := http.Client{Jar: c.cookies} + req, err := http.NewRequest("POST", url, arBuf) + if err != nil { + return u, err + } + req.SetBasicAuth(c.user, c.passwd) + req.Header.Set("Accept", "application/xml") + req.Header.Set("Content-Type", "application/xml") + resp, err := client.Do(req) + if err != nil { + return u, err + } + defer resp.Body.Close() + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return u, err + } + + switch resp.StatusCode { + case 400: + er := Error{} + err = xml.Unmarshal(body, &er) + if err != nil { + return u, err + } + + return u, fmt.Errorf("%s", er.Reason) + case 200: + err = xml.Unmarshal(body, &u) + if err != nil { + return u, err + } + default: + return u, fmt.Errorf("request failed: %s\n", resp.Status) + } + + return u, nil +} diff --git a/vendor/github.com/jda/go-crowd/auth_test.go b/vendor/github.com/jda/go-crowd/auth_test.go new file mode 100644 index 0000000..e5a75b3 --- /dev/null +++ b/vendor/github.com/jda/go-crowd/auth_test.go @@ -0,0 +1,34 @@ +package crowd + +import ( + "os" + "testing" +) + +func TestAuthentication(t *testing.T) { + tv := PrepVars(t) + c, err := New(tv.AppUsername, tv.AppPassword, tv.AppURL) + if err != nil { + t.Error(err) + } + + user := os.Getenv("APP_USER_USERNAME") + if user == "" { + t.Skip("Can't run test because APP_USER_USERNAME undefined") + } + + passwd := os.Getenv("APP_USER_PASSWORD") + if passwd == "" { + t.Skip("Can't run test because APP_USER_PASSWORD undefined") + } + + a, err := c.Authenticate(user, passwd) + if err != nil { + t.Error(err) + } + t.Logf("Got: %+v\n", a) + + if a.UserName == "" { + t.Errorf("UserName was empty so we didn't get/decode a response") + } +} diff --git a/vendor/github.com/jda/go-crowd/base.go b/vendor/github.com/jda/go-crowd/base.go new file mode 100644 index 0000000..128b9cb --- /dev/null +++ b/vendor/github.com/jda/go-crowd/base.go @@ -0,0 +1,41 @@ +// Package crowd provides methods for interacting with the +// Atlassian Crowd authentication, directory integration, and +// Single Sign-On system. +package crowd + +import ( + "net/http" + "net/http/cookiejar" +) + +// Crowd represents your Crowd (client) Application settings +type Crowd struct { + user string + passwd string + url string + cookies http.CookieJar +} + +// New initializes & returns a Crowd object. +func New(appuser string, apppass string, baseurl string) (Crowd, error) { + cr := Crowd{ + user: appuser, + passwd: apppass, + url: baseurl, + } + + // TODO make sure URL ends with '/' + + cj, err := cookiejar.New(nil) + if err != nil { + return cr, err + } + + cr.cookies = cj + + return cr, nil +} + +func (c *Crowd) get() { + +} diff --git a/vendor/github.com/jda/go-crowd/base_test.go b/vendor/github.com/jda/go-crowd/base_test.go new file mode 100644 index 0000000..c6f9d18 --- /dev/null +++ b/vendor/github.com/jda/go-crowd/base_test.go @@ -0,0 +1,40 @@ +package crowd + +import ( + "os" + "testing" +) + +type TestVars struct { + AppUsername string + AppPassword string + AppURL string +} + +// Make sure we have the env vars to run, handle bailing if we don't +func PrepVars(t *testing.T) TestVars { + var tv TestVars + + appU := os.Getenv("APP_USERNAME") + if appU == "" { + t.Skip("Can't run test because APP_USERNAME undefined") + } else { + tv.AppUsername = appU + } + + appP := os.Getenv("APP_PASSWORD") + if appP == "" { + t.Skip("Can't run test because APP_PASSWORD undefined") + } else { + tv.AppPassword = appP + } + + appURL := os.Getenv("APP_URL") + if appURL == "" { + t.Skip("Can't run test because APP_URL undefined") + } else { + tv.AppURL = appURL + } + + return tv +} diff --git a/vendor/github.com/jda/go-crowd/cookie.go b/vendor/github.com/jda/go-crowd/cookie.go new file mode 100644 index 0000000..67343a7 --- /dev/null +++ b/vendor/github.com/jda/go-crowd/cookie.go @@ -0,0 +1,50 @@ +package crowd + +import ( + "encoding/xml" + "fmt" + "io/ioutil" + "net/http" +) + +// CookieConfig holds configuration values needed to set a Crowd SSO cookie. +type CookieConfig struct { + XMLName struct{} `xml:"cookie-config"` + Domain string `xml:"domain"` + Secure bool `xml:"secure"` + Name string `xml:"name"` +} + +// GetCookieConfig returns settings needed to set a Crowd SSO cookie. +func (c *Crowd) GetCookieConfig() (CookieConfig, error) { + cc := CookieConfig{} + + client := http.Client{Jar: c.cookies} + req, err := http.NewRequest("GET", c.url+"rest/usermanagement/1/config/cookie", nil) + if err != nil { + return cc, err + } + req.SetBasicAuth(c.user, c.passwd) + req.Header.Set("Accept", "application/xml") + req.Header.Set("Content-Type", "application/xml") + resp, err := client.Do(req) + if err != nil { + return cc, err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return cc, fmt.Errorf("request failed: %s\n", resp.Status) + } + + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return cc, err + } + + err = xml.Unmarshal(body, &cc) + if err != nil { + return cc, err + } + + return cc, nil +} diff --git a/vendor/github.com/jda/go-crowd/cookie_test.go b/vendor/github.com/jda/go-crowd/cookie_test.go new file mode 100644 index 0000000..150eed0 --- /dev/null +++ b/vendor/github.com/jda/go-crowd/cookie_test.go @@ -0,0 +1,19 @@ +package crowd + +import ( + "testing" +) + +func TestGetCookieConfig(t *testing.T) { + tv := PrepVars(t) + c, err := New(tv.AppUsername, tv.AppPassword, tv.AppURL) + if err != nil { + t.Error(err) + } + + ck, err := c.GetCookieConfig() + if err != nil { + t.Error(err) + } + t.Logf("Got: %+v\n", ck) +} diff --git a/vendor/github.com/jda/go-crowd/crowdauth/loginTemplate.go b/vendor/github.com/jda/go-crowd/crowdauth/loginTemplate.go new file mode 100644 index 0000000..6e2c1de --- /dev/null +++ b/vendor/github.com/jda/go-crowd/crowdauth/loginTemplate.go @@ -0,0 +1,15 @@ +package crowdauth + +var defLoginPage string = ` +Login required + +

Login required

+
+ +
+ + + +
+ +` diff --git a/vendor/github.com/jda/go-crowd/crowdauth/middleware.go b/vendor/github.com/jda/go-crowd/crowdauth/middleware.go new file mode 100644 index 0000000..bfe7eb4 --- /dev/null +++ b/vendor/github.com/jda/go-crowd/crowdauth/middleware.go @@ -0,0 +1,205 @@ +// Package crowdauth provides middleware for Crowd SSO logins +// +// Goals: +// 1) drop-in authentication against Crowd SSO +// 2) make it easy to use Crowd SSO as part of your own auth flow +package crowdauth // import "go.jona.me/crowd/crowdauth" + +import ( + "errors" + "go.jona.me/crowd" + "html/template" + "log" + "net/http" + "time" +) + +type SSO struct { + CrowdApp *crowd.Crowd + LoginPage AuthLoginPage + LoginTemplate *template.Template + ClientAddressFinder ClientAddressFinder + CookieConfig crowd.CookieConfig +} + +// The AuthLoginPage type extends the normal http.HandlerFunc type +// with a boolean return to indicate login success or failure. +type AuthLoginPage func(http.ResponseWriter, *http.Request, *SSO) bool + +// ClientAddressFinder type represents a function that returns the +// end-user's IP address, allowing you to handle cases where the address +// is masked by proxy servers etc by checking headers or whatever to find +// the user's address +type ClientAddressFinder func(*http.Request) (string, error) + +var authErr string = "unauthorized, login required" + +func DefaultClientAddressFinder(r *http.Request) (addr string, err error) { + return r.RemoteAddr, nil +} + +// New creates a new instance of SSO +func New(user string, password string, crowdURL string) (s *SSO, err error) { + s = &SSO{} + s.LoginPage = loginPage + s.ClientAddressFinder = DefaultClientAddressFinder + s.LoginTemplate = template.Must(template.New("authPage").Parse(defLoginPage)) + + cwd, err := crowd.New(user, password, crowdURL) + if err != nil { + return s, err + } + s.CrowdApp = &cwd + s.CookieConfig, err = s.CrowdApp.GetCookieConfig() + if err != nil { + return s, err + } + + return s, nil +} + +// Handler provides HTTP middleware using http.Handler chaining +// that requires user authentication via Atlassian Crowd SSO. +func (s *SSO) Handler(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if s.loginHandler(w, r) == false { + return + } + h.ServeHTTP(w, r) + }) + +} + +func (s *SSO) loginHandler(w http.ResponseWriter, r *http.Request) bool { + ck, err := r.Cookie(s.CookieConfig.Name) + + if err == http.ErrNoCookie { + // no cookie so show login page if GET + // if POST check if login and handle + // if fail, show login page with message + if r.Method == "GET" { + s.LoginPage(w, r, s) + } else if r.Method == "POST" { + authOK := s.LoginPage(w, r, s) + if authOK == true { + // Redirect for fresh pass through auth etc on success + http.Redirect(w, r, r.RequestURI, http.StatusTemporaryRedirect) + return false + } else { + log.Printf("crowdauth: authentication failed\n") + } + } else { + http.Error(w, authErr, http.StatusUnauthorized) + } + return false + } else { + // validate cookie or show login page + host, err := s.ClientAddressFinder(r) + if err != nil { + log.Printf("crowdauth: could not get remote addr: %s\n", err) + return false + } + + _, err = s.CrowdApp.ValidateSession(ck.Value, host) + if err != nil { + log.Printf("crowdauth: could not validate cookie, deleting because: %s\n", err) + s.EndSession(w, r) + s.LoginPage(w, r, s) + return false + } + + // valid cookie so fallthrough + } + return true +} + +func (s *SSO) Login(user string, pass string, addr string) (cs crowd.Session, err error) { + cs, err = s.CrowdApp.NewSession(user, pass, addr) + return cs, err +} + +func (s *SSO) Logout(w http.ResponseWriter, r *http.Request, newURL string) { + s.EndSession(w, r) + http.Redirect(w, r, newURL, http.StatusTemporaryRedirect) +} + +// StartSession sets a Crowd session cookie. +func (s *SSO) StartSession(w http.ResponseWriter, cs crowd.Session) { + ck := http.Cookie{ + Name: s.CookieConfig.Name, + Domain: s.CookieConfig.Domain, + Secure: s.CookieConfig.Secure, + Value: cs.Token, + Expires: cs.Expires, + } + http.SetCookie(w, &ck) +} + +// EndSession invalidates the current Crowd session and cookie +func (s *SSO) EndSession(w http.ResponseWriter, r *http.Request) { + currentCookie, _ := r.Cookie(s.CookieConfig.Name) + newCookie := &http.Cookie{ + Name: s.CookieConfig.Name, + Domain: s.CookieConfig.Domain, + Secure: s.CookieConfig.Secure, + MaxAge: -1, + Expires: time.Unix(1, 0), + Value: "LOGGED-OUT", + } + s.CrowdApp.InvalidateSession(currentCookie.Value) + + log.Printf("Got cookie to remove: %+v\n", currentCookie) + log.Printf("Removal cookie: %+v\n", newCookie) + http.SetCookie(w, newCookie) +} + +// Get User information for the current session (by cookie) +func (s *SSO) GetUser(r *http.Request) (u crowd.User, err error) { + currentCookie, err := r.Cookie(s.CookieConfig.Name) + if err == http.ErrNoCookie { + return u, errors.New("no session cookie") + } + + userSession, err := s.CrowdApp.GetSession(currentCookie.Value) + if err != nil { + return u, errors.New("session not valid") + } + + return userSession.User, nil +} + +func loginPage(w http.ResponseWriter, r *http.Request, s *SSO) bool { + if r.Method == "GET" { // show login page and bail + showLoginPage(w, s) + return false + } else if r.Method == "POST" { + user := r.FormValue("username") + pass := r.FormValue("password") + host, err := s.ClientAddressFinder(r) + if err != nil { + log.Printf("crowdauth: could not get remote addr: %s\n", err) + showLoginPage(w, s) + return false + } + + sess, err := s.Login(user, pass, host) + if err != nil { + log.Printf("crowdauth: login/new session failed: %s\n", err) + showLoginPage(w, s) + return false + } + + s.StartSession(w, sess) + } else { + return false + } + + return true +} + +func showLoginPage(w http.ResponseWriter, s *SSO) { + err := s.LoginTemplate.ExecuteTemplate(w, "authPage", nil) + if err != nil { + log.Printf("crowdauth: could not exec template: %s\n", err) + } +} diff --git a/vendor/github.com/jda/go-crowd/error.go b/vendor/github.com/jda/go-crowd/error.go new file mode 100644 index 0000000..e49f9c9 --- /dev/null +++ b/vendor/github.com/jda/go-crowd/error.go @@ -0,0 +1,9 @@ +package crowd + +// Error represents a error response from Crowd. +// Error reasons are documented at https://developer.atlassian.com/display/CROWDDEV/Using+the+Crowd+REST+APIs#UsingtheCrowdRESTAPIs-HTTPResponseCodesandErrorResponses +type Error struct { + XMLName struct{} `xml:"error"` + Reason string `xml:"reason"` + Message string `xml:"message"` +} diff --git a/vendor/github.com/jda/go-crowd/groups.go b/vendor/github.com/jda/go-crowd/groups.go new file mode 100644 index 0000000..71f6240 --- /dev/null +++ b/vendor/github.com/jda/go-crowd/groups.go @@ -0,0 +1,87 @@ +package crowd + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/url" +) + +// Link is a child of Group +type Link struct { + Href string `json:"href"` + Rel string `json:"rel"` +} + +// Group represents a group in Crowd +type Group struct { + Name string `json:"name"` + Link Link `json:"link"` +} + +// Groups come in lists +type listGroups struct { + Groups []*Group `json:"groups"` + Expand string `json:"expand"` +} + +// GetGroups retrieves a list of groups of which a user is a direct (and nested if donested is true) member. +func (c *Crowd) GetGroups(user string, donested bool) ([]*Group, error) { + groupList := listGroups{} + + v := url.Values{} + v.Set("username", user) + var endpoint string + + if donested { + endpoint = "nested" + } else { + endpoint = "direct" + } + + url := c.url + "rest/usermanagement/1/user/group/" + endpoint + "?" + v.Encode() + client := http.Client{Jar: c.cookies} + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return groupList.Groups, err + } + req.SetBasicAuth(c.user, c.passwd) + req.Header.Set("Accept", "application/json") + resp, err := client.Do(req) + if err != nil { + return groupList.Groups, err + } + defer resp.Body.Close() + + switch resp.StatusCode { + case 404: + return groupList.Groups, fmt.Errorf("user not found") + case 200: + // fall through switch without returning + default: + return groupList.Groups, fmt.Errorf("request failed: %s", resp.Status) + } + + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return groupList.Groups, err + } + + err = json.Unmarshal(body, &groupList) + if err != nil { + return groupList.Groups, err + } + + return groupList.Groups, nil +} + +// GetNestedGroups retrieves a list of groups of which a user is a direct or nested member +func (c *Crowd) GetNestedGroups(user string) ([]*Group, error) { + return c.GetGroups(user, true) +} + +// GetDirectGroups retrieves a list of groups of which a user is a direct member +func (c *Crowd) GetDirectGroups(user string) ([]*Group, error) { + return c.GetGroups(user, false) +} diff --git a/vendor/github.com/jda/go-crowd/groups_test.go b/vendor/github.com/jda/go-crowd/groups_test.go new file mode 100644 index 0000000..be8018c --- /dev/null +++ b/vendor/github.com/jda/go-crowd/groups_test.go @@ -0,0 +1,62 @@ +package crowd + +import ( + "os" + "testing" +) + +func TestGetDirectGroups(t *testing.T) { + tv := PrepVars(t) + c, err := New(tv.AppUsername, tv.AppPassword, tv.AppURL) + if err != nil { + t.Error(err) + } + + user := os.Getenv("APP_USER_USERNAME") + if user == "" { + t.Skip("Can't run test because APP_USER_USERNAME undefined") + } + + // test new session + groups, err := c.GetDirectGroups(user) + if err != nil { + t.Errorf("Error getting user's direct group membership list: %s\n", err) + } else { + t.Logf("Got user's direct group membership list:") + for _, element := range groups { + t.Logf(" %s", element.Name) + } + } + + if len(groups) == 0 { + t.Error("groups list was empty so we didn't get/decode a response from GetIndirectGroups") + } +} + +func TestGetNestedGroups(t *testing.T) { + tv := PrepVars(t) + c, err := New(tv.AppUsername, tv.AppPassword, tv.AppURL) + if err != nil { + t.Error(err) + } + + user := os.Getenv("APP_USER_USERNAME") + if user == "" { + t.Skip("Can't run test because APP_USER_USERNAME undefined") + } + + // test new session + groups, err := c.GetNestedGroups(user) + if err != nil { + t.Errorf("Error getting user's nested group membership list: %s\n", err) + } else { + t.Logf("Got user's nested group membership list:") + for _, element := range groups { + t.Logf(" %s", element.Name) + } + } + + if len(groups) == 0 { + t.Error("groups list was empty so we didn't get/decode a response from GetIndirectGroups") + } +} diff --git a/vendor/github.com/jda/go-crowd/sso.go b/vendor/github.com/jda/go-crowd/sso.go new file mode 100644 index 0000000..59cc3a4 --- /dev/null +++ b/vendor/github.com/jda/go-crowd/sso.go @@ -0,0 +1,221 @@ +package crowd + +import ( + "bytes" + "encoding/xml" + "fmt" + "io/ioutil" + "net/http" + "time" +) + +// Session represents a single sign-on (SSO) session in Crowd +type Session struct { + XMLName struct{} `xml:"session"` + Expand string `xml:"expand,attr"` + Token string `xml:"token"` + User User `xml:"user,omitempty"` + Created time.Time `xml:"created-date"` + Expires time.Time `xml:"expiry-date"` +} + +// session authentication request +type sessionAuthReq struct { + XMLName struct{} `xml:"authentication-context"` + Username string `xml:"username"` + Password string `xml:"password"` + ValidationFactors []sessionValidationFactor `xml:"validation-factors>validation-factor"` +} + +// validation factors for session +type sessionValidationFactor struct { + XMLName struct{} `xml:"validation-factor"` + Name string `xml:"name"` + Value string `xml:"value"` +} + +// session validation request -> just validation factors +type sessionValidationValidationFactor struct { + XMLName struct{} `xml:"validation-factors"` + ValidationFactors []sessionValidationFactor `xml:"validation-factor"` +} + +// Authenticate a user and start a SSO session if valid. +func (c *Crowd) NewSession(user string, pass string, address string) (Session, error) { + s := Session{} + + svf := sessionValidationFactor{Name: "remote_address", Value: address} + sar := sessionAuthReq{Username: user, Password: pass} + sar.ValidationFactors = append(sar.ValidationFactors, svf) + + sarEncoded, err := xml.Marshal(sar) + if err != nil { + return s, err + } + sarBuf := bytes.NewBuffer(sarEncoded) + + url := c.url + "rest/usermanagement/1/session" + + client := http.Client{Jar: c.cookies} + req, err := http.NewRequest("POST", url, sarBuf) + if err != nil { + return s, err + } + req.SetBasicAuth(c.user, c.passwd) + req.Header.Set("Accept", "application/xml") + req.Header.Set("Content-Type", "application/xml") + resp, err := client.Do(req) + if err != nil { + return s, err + } + defer resp.Body.Close() + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return s, err + } + + switch resp.StatusCode { + case 400: + er := Error{} + err = xml.Unmarshal(body, &er) + if err != nil { + return s, err + } + + return s, fmt.Errorf("%s", er.Reason) + case 201: + err = xml.Unmarshal(body, &s) + if err != nil { + return s, err + } + default: + return s, fmt.Errorf("request failed: %s\n", resp.Status) + } + + return s, nil +} + +// Validate a SSO token against Crowd. Returns error on failure +// or account lockout. Success is a populated Session with nil error. +func (c *Crowd) ValidateSession(token string, clientaddr string) (Session, error) { + s := Session{} + + svf := sessionValidationFactor{Name: "remote_address", Value: clientaddr} + svvf := sessionValidationValidationFactor{} + svvf.ValidationFactors = append(svvf.ValidationFactors, svf) + + svvfEncoded, err := xml.Marshal(svvf) + if err != nil { + return s, err + } + svvfBuf := bytes.NewBuffer(svvfEncoded) + + url := c.url + "rest/usermanagement/1/session/" + token + + client := http.Client{Jar: c.cookies} + req, err := http.NewRequest("POST", url, svvfBuf) + if err != nil { + return s, err + } + req.SetBasicAuth(c.user, c.passwd) + req.Header.Set("Accept", "application/xml") + req.Header.Set("Content-Type", "application/xml") + resp, err := client.Do(req) + if err != nil { + return s, err + } + defer resp.Body.Close() + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return s, err + } + + switch resp.StatusCode { + case 400: + er := Error{} + err = xml.Unmarshal(body, &er) + if err != nil { + return s, err + } + + return s, fmt.Errorf("%s", er.Reason) + case 404: + er := Error{} + err = xml.Unmarshal(body, &er) + if err != nil { + return s, err + } + + return s, fmt.Errorf("%s", er.Reason) + case 200: + err = xml.Unmarshal(body, &s) + if err != nil { + return s, err + } + default: + return s, fmt.Errorf("request failed: %s\n", resp.Status) + } + + return s, nil +} + +// Invalidate SSO session token. Returns error on failure. +func (c *Crowd) InvalidateSession(token string) error { + client := http.Client{Jar: c.cookies} + req, err := http.NewRequest("DELETE", c.url+"rest/usermanagement/1/session/"+token, nil) + if err != nil { + return err + } + req.SetBasicAuth(c.user, c.passwd) + req.Header.Set("Accept", "application/xml") + req.Header.Set("Content-Type", "application/xml") + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != 204 { + return fmt.Errorf("request failed: %s\n", resp.Status) + } + + return nil +} + +// Get SSO session information by token +func (c *Crowd) GetSession(token string) (s Session, err error) { + client := http.Client{Jar: c.cookies} + url := c.url + "rest/usermanagement/1/session/" + token + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return s, err + } + req.SetBasicAuth(c.user, c.passwd) + req.Header.Set("Accept", "application/xml") + req.Header.Set("Content-Type", "application/xml") + resp, err := client.Do(req) + if err != nil { + return s, err + } + defer resp.Body.Close() + + switch resp.StatusCode { + case 404: + return s, fmt.Errorf("session not found") + case 200: + // fall through switch without returning + default: + return s, fmt.Errorf("request failed: %s\n", resp.Status) + } + + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return s, err + } + + err = xml.Unmarshal(body, &s) + if err != nil { + return s, err + } + + return s, nil +} diff --git a/vendor/github.com/jda/go-crowd/sso_test.go b/vendor/github.com/jda/go-crowd/sso_test.go new file mode 100644 index 0000000..5838471 --- /dev/null +++ b/vendor/github.com/jda/go-crowd/sso_test.go @@ -0,0 +1,75 @@ +package crowd + +import ( + "os" + "testing" +) + +func TestSSOLifeCycle(t *testing.T) { + tv := PrepVars(t) + c, err := New(tv.AppUsername, tv.AppPassword, tv.AppURL) + if err != nil { + t.Error(err) + } + + user := os.Getenv("APP_USER_USERNAME") + if user == "" { + t.Skip("Can't run test because APP_USER_USERNAME undefined") + } + + passwd := os.Getenv("APP_USER_PASSWORD") + if passwd == "" { + t.Skip("Can't run test because APP_USER_PASSWORD undefined") + } + + addr := "10.10.10.10" + + // test new session + a, err := c.NewSession(user, passwd, addr) + if err != nil { + t.Errorf("Error creating new session: %s\n", err) + } else { + t.Logf("Got new session: %+v\n", a) + } + + if a.Token == "" { + t.Errorf("Token was empty so we didn't get/decode a response from NewSession") + } + + // test validate for session we just created + si, err := c.ValidateSession(a.Token, addr) + if err != nil { + t.Errorf("Error validating session: %s\n", err) + } else { + t.Logf("Validated session: %+v\n", si) + } + + if si.Token == "" { + t.Errorf("Token was empty so we didn't get/decode a response from ValidateSession") + } + + // test get info for session + sdat, err := c.GetSession(a.Token) + if err != nil { + t.Errorf("Error getting session: %s\n", err) + } else { + t.Logf("Got session: %+v\n", sdat) + } + + // test invalidating session + err = c.InvalidateSession(a.Token) + if err != nil { + t.Errorf("Error invalidating session: %s\n", err) + } else { + t.Log("Invalidated session") + } + + // make sure sesssion is gone + ivsess, err := c.ValidateSession(a.Token, addr) + if err == nil { + t.Errorf("Validating non-existant session should fail, got: %+v\n", ivsess) + } else { + t.Log("Could not validate session that doesn't exist (this is good)") + } + +} diff --git a/vendor/github.com/jda/go-crowd/user.go b/vendor/github.com/jda/go-crowd/user.go new file mode 100644 index 0000000..8ac7089 --- /dev/null +++ b/vendor/github.com/jda/go-crowd/user.go @@ -0,0 +1,64 @@ +package crowd + +import ( + "encoding/xml" + "fmt" + "io/ioutil" + "net/http" + "net/url" +) + +// User represents a user in Crowd +type User struct { + XMLName struct{} `xml:"user"` + UserName string `xml:"name,attr"` + FirstName string `xml:"first-name"` + LastName string `xml:"last-name"` + DisplayName string `xml:"display-name"` + Email string `xml:"email"` + Active bool `xml:"active"` + Key string `xml:"key"` +} + +// GetUser retrieves user information +func (c *Crowd) GetUser(user string) (User, error) { + u := User{} + + v := url.Values{} + v.Set("username", user) + url := c.url + "rest/usermanagement/1/user?" + v.Encode() + client := http.Client{Jar: c.cookies} + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return u, err + } + req.SetBasicAuth(c.user, c.passwd) + req.Header.Set("Accept", "application/xml") + req.Header.Set("Content-Type", "application/xml") + resp, err := client.Do(req) + if err != nil { + return u, err + } + defer resp.Body.Close() + + switch resp.StatusCode { + case 404: + return u, fmt.Errorf("user not found") + case 200: + // fall through switch without returning + default: + return u, fmt.Errorf("request failed: %s\n", resp.Status) + } + + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return u, err + } + + err = xml.Unmarshal(body, &u) + if err != nil { + return u, err + } + + return u, nil +} diff --git a/vendor/github.com/jda/go-crowd/user_test.go b/vendor/github.com/jda/go-crowd/user_test.go new file mode 100644 index 0000000..46275b3 --- /dev/null +++ b/vendor/github.com/jda/go-crowd/user_test.go @@ -0,0 +1,32 @@ +package crowd + +import ( + "os" + "testing" +) + +func TestGetUser(t *testing.T) { + tv := PrepVars(t) + c, err := New(tv.AppUsername, tv.AppPassword, tv.AppURL) + if err != nil { + t.Error(err) + } + + user := os.Getenv("APP_USER_USERNAME") + if user == "" { + t.Skip("Can't run test because APP_USER_USERNAME undefined") + } + + // test new session + u, err := c.GetUser(user) + if err != nil { + t.Errorf("Error getting user info: %s\n", err) + } else { + t.Logf("Got user info: %+v\n", u) + } + + if u.UserName == "" { + t.Errorf("username was empty so we didn't get/decode a response from GetUser") + } + +}