From 3e006c39b0a4411e91e80de261d0e7b5353d44c0 Mon Sep 17 00:00:00 2001 From: Kevin Lyda Date: Fri, 10 Aug 2018 17:21:02 +0000 Subject: Add Microsoft auth provider Microsoft uses JSON Web Tokens (JWT) as OAuth tokens. These can run to many thousands of characters which are too long for TTYs. Work around this by base64-encoding the token and chunk it into smaller pieces. Closes #70 --- README.md | 6 +- cmd/cashier/main.go | 15 +- server/auth/microsoft/microsoft.go | 203 ++++++++++++++++++++++ server/auth/microsoft/microsoft_test.go | 72 ++++++++ server/server.go | 7 +- server/web.go | 20 +++ vendor/golang.org/x/oauth2/microsoft/microsoft.go | 31 ++++ vendor/vendor.json | 6 + 8 files changed, 354 insertions(+), 6 deletions(-) create mode 100644 server/auth/microsoft/microsoft.go create mode 100644 server/auth/microsoft/microsoft_test.go create mode 100644 vendor/golang.org/x/oauth2/microsoft/microsoft.go diff --git a/README.md b/README.md index bcf0845..9507f3a 100644 --- a/README.md +++ b/README.md @@ -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 @@ -978,6 +978,12 @@ "revision": "ef147856a6ddbb60760db74283d2424e98c87bff", "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", -- cgit v1.2.3