diff options
-rw-r--r-- | README.md | 6 | ||||
-rw-r--r-- | cmd/cashier/main.go | 15 | ||||
-rw-r--r-- | server/auth/microsoft/microsoft.go | 203 | ||||
-rw-r--r-- | server/auth/microsoft/microsoft_test.go | 72 | ||||
-rw-r--r-- | server/server.go | 7 | ||||
-rw-r--r-- | server/web.go | 20 | ||||
-rw-r--r-- | vendor/golang.org/x/oauth2/microsoft/microsoft.go | 31 | ||||
-rw-r--r-- | vendor/vendor.json | 6 |
8 files changed, 354 insertions, 6 deletions
@@ -178,11 +178,13 @@ Supported options: | Provider | Option | Notes | |---------:|-------------:|----------------------------------------------------------------------------------------------------------------------------------------| -| Google | domain | If this is unset then you must whitelist individual email addresses using `users_whitelist`. | | Github | organization | If this is unset then you must whitelist individual users using `users_whitelist`. The oauth client and secrets should be issued by the specified organization. | -| Gitlab | siteurl | Optional. The url of the Gitlab site. Default: `https://gitlab.com/api/v3/` | | Gitlab | allusers | Allow all valid users to get signed keys. Only allowed if siteurl set. | | Gitlab | group | If `allusers` and this are unset then you must whitelist individual users using `users_whitelist`. Otherwise the user must be a member of this group. | +| Gitlab | siteurl | Optional. The url of the Gitlab site. Default: `https://gitlab.com/api/v3/` | +| Google | domain | If this is unset then you must whitelist individual email addresses using `users_whitelist`. | +| Microsoft | groups | Comma separated list of valid groups. | +| Microsoft | tenant | The domain name of the Office 365 account. | ## ssh - `signing_key`: string. Path to the certificate signing ssh private key. Use `ssh-keygen` to create the key and store it somewhere safe. See also the [note](#a-note-on-files) on files above. diff --git a/cmd/cashier/main.go b/cmd/cashier/main.go index f448a25..1ee9455 100644 --- a/cmd/cashier/main.go +++ b/cmd/cashier/main.go @@ -1,6 +1,9 @@ package main import ( + "bufio" + "bytes" + "encoding/base64" "fmt" "log" "net" @@ -46,8 +49,16 @@ func main() { } fmt.Print("Enter token: ") - var token string - fmt.Scanln(&token) + scanner := bufio.NewScanner(os.Stdin) + var buffer bytes.Buffer + for scanner.Scan(); scanner.Text() != "."; scanner.Scan() { + buffer.WriteString(scanner.Text()) + } + tokenBytes, err := base64.StdEncoding.DecodeString(buffer.String()) + if err != nil { + log.Fatalln(err) + } + token := string(tokenBytes) cert, err := client.Sign(pub, token, c) if err != nil { diff --git a/server/auth/microsoft/microsoft.go b/server/auth/microsoft/microsoft.go new file mode 100644 index 0000000..49d9b82 --- /dev/null +++ b/server/auth/microsoft/microsoft.go @@ -0,0 +1,203 @@ +package microsoft + +import ( + "encoding/json" + "errors" + "net/http" + "path" + "strings" + + "github.com/nsheridan/cashier/server/auth" + "github.com/nsheridan/cashier/server/config" + "github.com/nsheridan/cashier/server/metrics" + + "golang.org/x/oauth2" + "golang.org/x/oauth2/microsoft" +) + +const ( + name = "microsoft" +) + +// Config is an implementation of `auth.Provider` for authenticating using a +// Office 365 account. +type Config struct { + config *oauth2.Config + tenant string + groups map[string]bool + whitelist map[string]bool +} + +var _ auth.Provider = (*Config)(nil) + +// New creates a new Microsoft provider from a configuration. +func New(c *config.Auth) (*Config, error) { + whitelist := make(map[string]bool) + for _, u := range c.UsersWhitelist { + whitelist[u] = true + } + if c.ProviderOpts["tenant"] == "" && len(whitelist) == 0 { + return nil, errors.New("either Office 365 tenant or users whitelist must be specified") + } + groupMap := make(map[string]bool) + if groups, ok := c.ProviderOpts["groups"]; ok { + for _, group := range strings.Split(groups, ",") { + groupMap[strings.Trim(group, " ")] = true + } + } + + return &Config{ + config: &oauth2.Config{ + ClientID: c.OauthClientID, + ClientSecret: c.OauthClientSecret, + RedirectURL: c.OauthCallbackURL, + Endpoint: microsoft.AzureADEndpoint(c.ProviderOpts["tenant"]), + Scopes: []string{"user.Read.All", "Directory.Read.All"}, + }, + tenant: c.ProviderOpts["tenant"], + whitelist: whitelist, + groups: groupMap, + }, nil +} + +// A new oauth2 http client. +func (c *Config) newClient(token *oauth2.Token) *http.Client { + return c.config.Client(oauth2.NoContext, token) +} + +// Gets a response for an graph api call. +func (c *Config) getDocument(token *oauth2.Token, pathElements ...string) map[string]interface{} { + client := c.newClient(token) + url := "https://" + path.Join("graph.microsoft.com/v1.0", path.Join(pathElements...)) + resp, err := client.Get(url) + if err != nil { + return nil + } + defer resp.Body.Close() + var document map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&document); err != nil { + return nil + } + return document +} + +// Get info from the "/me" endpoint of the Microsoft Graph API (MSG-API). +// https://developer.microsoft.com/en-us/graph/docs/concepts/v1-overview +func (c *Config) getMe(token *oauth2.Token, item string) string { + document := c.getDocument(token, "/me") + if value, ok := document[item].(string); ok { + return value + } + return "" +} + +// Check against verified domains from "/organization" endpoint of MSG-API. +func (c *Config) verifyTenant(token *oauth2.Token) bool { + document := c.getDocument(token, "/organization") + // The domains for an organisation are in an array of structs under + // verifiedDomains, which is in a struct which is in turn an array + // of such structs under value in the document. Which in json looks + // like this: + // { "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#organization", + // "value": [ { + // ... + // "verifiedDomains": [ { + // ... + // "name": "M365x214355.onmicrosoft.com", + // } ] + // } ] + //} + var value []interface{} + var ok bool + if value, ok = document["value"].([]interface{}); !ok { + return false + } + for _, valueEntry := range value { + if value, ok = valueEntry.(map[string]interface{})["verifiedDomains"].([]interface{}); !ok { + continue + } + for _, val := range value { + domain := val.(map[string]interface{})["name"].(string) + if domain == c.tenant { + return true + } + } + } + return false +} + +// Check against groups from /users/{id}/memberOf endpoint of MSG-API. +func (c *Config) verifyGroups(token *oauth2.Token) bool { + document := c.getDocument(token, "/users/me/memberOf") + var value []interface{} + var ok bool + if value, ok = document["value"].([]interface{}); !ok { + return false + } + for _, valueEntry := range value { + if group, ok := valueEntry.(map[string]interface{})["displayName"].(string); ok { + if c.groups[group] { + return true + } + } + } + return false +} + +// Name returns the name of the provider. +func (c *Config) Name() string { + return name +} + +// Valid validates the oauth token. +func (c *Config) Valid(token *oauth2.Token) bool { + if len(c.whitelist) > 0 && !c.whitelist[c.Email(token)] { + return false + } + if !token.Valid() { + return false + } + metrics.M.AuthValid.WithLabelValues("microsoft").Inc() + if c.tenant != "" { + if c.verifyTenant(token) { + if len(c.groups) > 0 { + return c.verifyGroups(token) + } + return true + } + } + return false +} + +// Revoke disables the access token. +func (c *Config) Revoke(token *oauth2.Token) error { + return nil +} + +// StartSession retrieves an authentication endpoint from Microsoft. +func (c *Config) StartSession(state string) *auth.Session { + return &auth.Session{ + AuthURL: c.config.AuthCodeURL(state, + oauth2.SetAuthURLParam("hd", c.tenant), + oauth2.SetAuthURLParam("prompt", "login")), + } +} + +// Exchange authorizes the session and returns an access token. +func (c *Config) Exchange(code string) (*oauth2.Token, error) { + t, err := c.config.Exchange(oauth2.NoContext, code) + if err == nil { + metrics.M.AuthExchange.WithLabelValues("microsoft").Inc() + } + return t, err +} + +// Email retrieves the email address of the user. +func (c *Config) Email(token *oauth2.Token) string { + return c.getMe(token, "mail") +} + +// Username retrieves the username portion of the user's email address. +func (c *Config) Username(token *oauth2.Token) string { + return strings.Split(c.Email(token), "@")[0] +} diff --git a/server/auth/microsoft/microsoft_test.go b/server/auth/microsoft/microsoft_test.go new file mode 100644 index 0000000..c2c2c17 --- /dev/null +++ b/server/auth/microsoft/microsoft_test.go @@ -0,0 +1,72 @@ +package microsoft + +import ( + "fmt" + "testing" + + "github.com/nsheridan/cashier/server/config" + "github.com/stretchr/testify/assert" +) + +var ( + oauthClientID = "id" + oauthClientSecret = "secret" + oauthCallbackURL = "url" + tenant = "example.com" + users = []string{"user"} +) + +func TestNew(t *testing.T) { + a := assert.New(t) + p, err := newMicrosoft() + a.NoError(err) + a.Equal(p.config.ClientID, oauthClientID) + a.Equal(p.config.ClientSecret, oauthClientSecret) + a.Equal(p.config.RedirectURL, oauthCallbackURL) + a.Equal(p.tenant, tenant) + a.Equal(p.whitelist, map[string]bool{"user": true}) +} + +func TestWhitelist(t *testing.T) { + c := &config.Auth{ + OauthClientID: oauthClientID, + OauthClientSecret: oauthClientSecret, + OauthCallbackURL: oauthCallbackURL, + ProviderOpts: map[string]string{"tenant": ""}, + UsersWhitelist: []string{}, + } + if _, err := New(c); err == nil { + t.Error("creating a provider without a tenant set should return an error") + } + // Set a user whitelist but no tenant + c.UsersWhitelist = users + if _, err := New(c); err != nil { + t.Error("creating a provider with users but no tenant should not return an error") + } + // Unset the user whitelist and set a tenant + c.UsersWhitelist = []string{} + c.ProviderOpts = map[string]string{"tenant": tenant} + if _, err := New(c); err != nil { + t.Error("creating a provider with a tenant set but without a user whitelist should not return an error") + } +} + +func TestStartSession(t *testing.T) { + a := assert.New(t) + + p, err := newMicrosoft() + a.NoError(err) + s := p.StartSession("test_state") + a.Contains(s.AuthURL, fmt.Sprintf("login.microsoftonline.com/%s/oauth2/v2.0/authorize", tenant)) +} + +func newMicrosoft() (*Config, error) { + c := &config.Auth{ + OauthClientID: oauthClientID, + OauthClientSecret: oauthClientSecret, + OauthCallbackURL: oauthCallbackURL, + ProviderOpts: map[string]string{"tenant": tenant}, + UsersWhitelist: users, + } + return New(c) +} diff --git a/server/server.go b/server/server.go index 42476f3..1b8468e 100644 --- a/server/server.go +++ b/server/server.go @@ -16,6 +16,7 @@ import ( "github.com/nsheridan/cashier/server/auth/github" "github.com/nsheridan/cashier/server/auth/gitlab" "github.com/nsheridan/cashier/server/auth/google" + "github.com/nsheridan/cashier/server/auth/microsoft" "github.com/nsheridan/cashier/server/config" "github.com/nsheridan/cashier/server/metrics" "github.com/nsheridan/cashier/server/signer" @@ -90,12 +91,14 @@ func Run(conf *config.Config) { metrics.Register() switch conf.Auth.Provider { - case "google": - authprovider, err = google.New(conf.Auth) case "github": authprovider, err = github.New(conf.Auth) case "gitlab": authprovider, err = gitlab.New(conf.Auth) + case "google": + authprovider, err = google.New(conf.Auth) + case "microsoft": + authprovider, err = microsoft.New(conf.Auth) default: log.Fatalf("Unknown provider %s\n", conf.Auth.Provider) } diff --git a/server/web.go b/server/web.go index e238150..d55aa52 100644 --- a/server/web.go +++ b/server/web.go @@ -1,7 +1,9 @@ package server import ( + "bytes" "crypto/rand" + "encoding/base64" "encoding/hex" "encoding/json" "fmt" @@ -189,6 +191,23 @@ func callbackHandler(a *appContext, w http.ResponseWriter, r *http.Request) (int return http.StatusFound, nil } +func encodeString(s string) string { + var buffer bytes.Buffer + chunkSize := 70 + runes := []rune(base64.StdEncoding.EncodeToString([]byte(s))) + + for i := 0; i < len(runes); i += chunkSize { + end := i + chunkSize + if end > len(runes) { + end = len(runes) + } + buffer.WriteString(string(runes[i:end])) + buffer.WriteString("\n") + } + buffer.WriteString(".\n") + return buffer.String() +} + // rootHandler starts the auth process. If the client is authenticated it renders the token to the user. func rootHandler(a *appContext, w http.ResponseWriter, r *http.Request) (int, error) { if !a.isLoggedIn(w, r) { @@ -198,6 +217,7 @@ func rootHandler(a *appContext, w http.ResponseWriter, r *http.Request) (int, er page := struct { Token string }{tok.AccessToken} + page.Token = encodeString(page.Token) tmpl := template.Must(template.New("token.html").Parse(templates.Token)) tmpl.Execute(w, page) diff --git a/vendor/golang.org/x/oauth2/microsoft/microsoft.go b/vendor/golang.org/x/oauth2/microsoft/microsoft.go new file mode 100644 index 0000000..3ffbc57 --- /dev/null +++ b/vendor/golang.org/x/oauth2/microsoft/microsoft.go @@ -0,0 +1,31 @@ +// Copyright 2016 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package microsoft provides constants for using OAuth2 to access Windows Live ID. +package microsoft // import "golang.org/x/oauth2/microsoft" + +import ( + "golang.org/x/oauth2" +) + +// LiveConnectEndpoint is Windows's Live ID OAuth 2.0 endpoint. +var LiveConnectEndpoint = oauth2.Endpoint{ + AuthURL: "https://login.live.com/oauth20_authorize.srf", + TokenURL: "https://login.live.com/oauth20_token.srf", +} + +// AzureADEndpoint returns a new oauth2.Endpoint for the given tenant at Azure Active Directory. +// If tenant is empty, it uses the tenant called `common`. +// +// For more information see: +// https://docs.microsoft.com/en-us/azure/active-directory/develop/active-directory-v2-protocols#endpoints +func AzureADEndpoint(tenant string) oauth2.Endpoint { + if tenant == "" { + tenant = "common" + } + return oauth2.Endpoint{ + AuthURL: "https://login.microsoftonline.com/" + tenant + "/oauth2/v2.0/authorize", + TokenURL: "https://login.microsoftonline.com/" + tenant + "/oauth2/v2.0/token", + } +} diff --git a/vendor/vendor.json b/vendor/vendor.json index aca060d..1b1f6cf 100644 --- a/vendor/vendor.json +++ b/vendor/vendor.json @@ -979,6 +979,12 @@ "revisionTime": "2018-06-20T17:47:24Z" }, { + "checksumSHA1": "91mzAbqHQ6AAK65DzB4IkLOcvtk=", + "path": "golang.org/x/oauth2/microsoft", + "revision": "ef147856a6ddbb60760db74283d2424e98c87bff", + "revisionTime": "2018-06-20T17:47:24Z" + }, + { "checksumSHA1": "S0DP7Pn7sZUmXc55IzZnNvERu6s=", "path": "golang.org/x/sync/errgroup", "revision": "1d60e4601c6fd243af51cc01ddf169918a5407ca", |