aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md6
-rw-r--r--cmd/cashier/main.go15
-rw-r--r--server/auth/microsoft/microsoft.go203
-rw-r--r--server/auth/microsoft/microsoft_test.go72
-rw-r--r--server/server.go7
-rw-r--r--server/web.go20
-rw-r--r--vendor/golang.org/x/oauth2/microsoft/microsoft.go31
-rw-r--r--vendor/vendor.json6
8 files changed, 354 insertions, 6 deletions
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
@@ -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",