3
0

Refactor credentials, migrate to /auth/external/ (from /social)

This commit is contained in:
Denis Arh
2019-04-03 15:09:31 +02:00
parent b84ed43f27
commit d5212f267c
39 changed files with 1426 additions and 788 deletions
+1 -8
View File
@@ -19,15 +19,8 @@ SYSTEM_DB_PROFILER=stdout
METRICS=1
METRICS_PASSWORD=metrics
AUTH_JWT_SECRET=123
AUTH_JWT_SECRET=jwt-secret
AUTH_JWT_DEBUG=1
AUTH_OIDC_ISSUER=123
AUTH_OIDC_REDIRECT_URL=213
AUTH_OIDC_COOKIE_DOMAIN=""
RBAC_AUTH=tenant:password
RBAC_TENANT=tenant
RBAC_BASE_URL=https://crust.example.org/pdp/
# Make sure you run `make mailhog.up` and visit localhost:8025.
SMTP_HOST=localhost:1025
Generated
+3 -1
View File
@@ -253,7 +253,7 @@
version = "v1.0.0"
[[projects]]
digest = "1:c6a991538ddd77daaad1380ec85e1d7c6a0ae2bc2732b059ddb95f9a49569d3d"
digest = "1:eb5f06b8a308f49f2205386975ab854606f01784546996964ade50fc41156875"
name = "github.com/markbates/goth"
packages = [
".",
@@ -262,6 +262,7 @@
"providers/github",
"providers/gplus",
"providers/linkedin",
"providers/openidConnect",
]
pruneopts = "UT"
revision = "157987f620ff2fc5e1f6a1427a3685219fbf6ff4"
@@ -554,6 +555,7 @@
"github.com/markbates/goth/providers/github",
"github.com/markbates/goth/providers/gplus",
"github.com/markbates/goth/providers/linkedin",
"github.com/markbates/goth/providers/openidConnect",
"github.com/namsral/flag",
"github.com/pkg/errors",
"github.com/prometheus/client_golang/prometheus",
-1
View File
@@ -27,7 +27,6 @@ func TestPermissions(t *testing.T) {
user := &systemTypes.User{
Name: "John Crm Doe",
Username: "johndoe",
SatosaID: "12345",
}
err := user.GeneratePassword("johndoe")
NoError(t, err, "expected no error generating password, got %+v", err)
+78
View File
@@ -0,0 +1,78 @@
# Authentication
Crust support a fixed set of standard OAuth 2 authentication providers
(facebook, gplus, github and linkedin) and a arbitrary number of custom
issuers (over OpenID Connect).
# Available settings
Settings for external providers are stored under keys
`auth.external.providers.<provider>.<prop>` and
`auth.external.providers.openid-connect.<provider>.<prop>`.
Prop is one of: `key`, `secret`, `enabled`. OIDC settings also have `issuer` prop.
Example settings (`system-cli settings list --prefix=auth.external`):
```
auth.external.callback-endpoint "http://system.api.local.crust.tech:3002/auth/external/%s/callback"
auth.external.enabled true
auth.external.providers.facebook.enabled true
auth.external.providers.facebook.key "24226007270326"
auth.external.providers.facebook.secret "7vtfXx213cfc125804a226afcae777fe47"
auth.external.providers.gplus true
auth.external.providers.gplus.enabled true
auth.external.providers.gplus.key "10629818561-7a8vr0avs47dqic43h2lkrurhr.apps.googleusercontent.com"
auth.external.providers.gplus.secret "bkHmIFdk2YvtfXx"
auth.external.providers.github.enabled false
auth.external.providers.github.key null
auth.external.providers.github.secret null
auth.external.providers.linkedin.enabled false
auth.external.providers.linkedin.key null
auth.external.providers.linkedin.secret null
auth.external.providers.openid-connect.didmos2.enabled true
auth.external.providers.openid-connect.didmos2.key "tXM2ouiovowzGabk"
auth.external.providers.openid-connect.didmos2.issuer "https://satosa.didmos.latest.crust.tech"
auth.external.providers.openid-connect.didmos2.secret "e1d68bfd7718468ba8fd36131f5176b1"
auth.external.redirect-url "http://system.api.local.crust.tech:3002/auth/external/%s/callback"
auth.external.session-store-secret "fCVFSRWjVEcoYuhXSf3f6zVWO1p38XEWz2yS8WH7wKDbvpxFrZq7zlEuiUTvk4QF"
```
# Changing settings
Authentication settings can be changed in the administration (via the API) and with cli
command (`system-cli settings set <key> <value>`). Please bare in mind that values passed
to CLI tool must always be in raw JSON format.
Changing values requires system service restart.
On startup, you should see log entries similar to these:
```
initializing external authentication providers (3)
external authentication provider "facebook" added
external authentication provider "gplus" added
external authentication provider "openid-connect.didmos2" added
```
# OIDC Auto discovery/configuration
Crust CLI comes with auto-discovery tool:
```bash
system-cli external-auth auto-discovery name url
```
```bash
system-cli external-auth auto-discovery didmos2 https://satosa.didmos.crust.example.tld
```
This will autodiscover and autoconfigure new OIDC provider.
If entry with this name already exists it will override it.
Please note that this provider is disabled by default.
To enable it, run:
```bash
system-cli settings key auth.external.providers.openid-connect.didmos2.enabled true
```
-60
View File
@@ -1,60 +0,0 @@
package config
import (
"github.com/namsral/flag"
"github.com/pkg/errors"
)
type (
OIDC struct {
Enabled bool
Issuer string
ClientID string
ClientSecret string
RedirectURL string
AppURL string
StateCookieDomain string
StateCookieExpiry int64
}
)
var oidc *OIDC
func (c *OIDC) Validate() error {
if c == nil {
return nil
}
if c.Enabled == false {
return nil
}
if c.Issuer == "" {
return errors.New("OIDC Issuer not set for AUTH")
}
if c.RedirectURL == "" {
return errors.New("OIDC RedirectURL not set for AUTH")
}
if c.StateCookieDomain == "" {
return errors.New("OIDC CookieDomain not set")
}
return nil
}
func (*OIDC) Init(prefix ...string) *OIDC {
if oidc != nil {
return oidc
}
oidc = new(OIDC)
flag.BoolVar(&oidc.Enabled, "auth-oidc-enabled", true, "OIDC enabled")
flag.StringVar(&oidc.Issuer, "auth-oidc-issuer", "", "OIDC Issuer")
flag.StringVar(&oidc.ClientID, "auth-oidc-client-id", "", "OIDC Client ID")
flag.StringVar(&oidc.ClientSecret, "auth-oidc-client-secret", "", "OIDC Client Secret")
flag.StringVar(&oidc.RedirectURL, "auth-oidc-redirect-url", "", "OIDC RedirectURL")
flag.StringVar(&oidc.AppURL, "auth-oidc-app-url", "", "OIDC AppURL")
flag.StringVar(&oidc.StateCookieDomain, "auth-oidc-cookie-domain", "", "JWT Cookie domain")
flag.Int64Var(&oidc.StateCookieExpiry, "auth-oidc-state-cookie-expiry", 15, "OIDC State cookie expiry in minutes")
return oidc
}
-71
View File
@@ -1,71 +0,0 @@
package config
import (
"errors"
"strings"
"github.com/namsral/flag"
)
type (
Social struct {
Enabled bool
FacebookKey string
FacebookSecret string
GPlusKey string
GPlusSecret string
GitHubKey string
GitHubSecret string
LinkedInKey string
LinkedInSecret string
Url string
SessionStoreSecret string
SessionStoreExpiry int // seconds!
}
)
var social *Social
func (c *Social) Validate() error {
if c == nil {
return nil
}
if c.Enabled == false {
return nil
}
if c.SessionStoreSecret == "" {
return errors.New("Session store secret not set for SOCIAL")
}
return nil
}
func (*Social) Init(prefix ...string) *Social {
if social != nil {
return social
}
b := func(name string, k, s *string) {
flag.StringVar(k, "auth-social-"+strings.ToLower(name)+"-key", "", name+" key")
flag.StringVar(s, "auth-social-"+strings.ToLower(name)+"-secret", "", name+" secret")
}
social = new(Social)
flag.BoolVar(&social.Enabled, "auth-social-enabled", false, "SocialAuth enabled")
b("Facebook", &social.FacebookKey, &social.FacebookSecret)
b("GPlus", &social.GPlusKey, &social.GPlusSecret)
b("GitHub", &social.GitHubKey, &social.GitHubSecret)
b("LinkedIn", &social.LinkedInKey, &social.LinkedInSecret)
flag.StringVar(&social.Url, "auth-social-url", "", "Base URL")
flag.StringVar(&social.SessionStoreSecret, "auth-social-session-store-secret", "", "Session store secret")
flag.IntVar(&social.SessionStoreExpiry, "auth-social-state-cookie-expiry", 60*15, "SocialAuth State cookie expiry in seconds")
return social
}
+41
View File
@@ -0,0 +1,41 @@
package rand
import (
"math/rand"
"sync"
"time"
)
// credits: https://stackoverflow.com/questions/22892120/how-to-generate-a-random-string-of-a-fixed-length-in-golang
const (
letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
letterIdxBits = 6 // 6 bits to represent a letter index
letterIdxMask = 1<<letterIdxBits - 1 // All 1-bits, as many as letterIdxBits
letterIdxMax = 63 / letterIdxBits // # of letter indices fitting in 63 bits
)
var randSrc = rand.NewSource(time.Now().UnixNano())
var mu sync.Mutex
func Bytes(n int) []byte {
mu.Lock()
defer mu.Unlock()
b := make([]byte, n)
// A randSrc.Int63() generates 63 random bits, enough for letterIdxMax characters!
for i, cache, remain := n-1, randSrc.Int63(), letterIdxMax; i >= 0; {
if remain == 0 {
cache, remain = randSrc.Int63(), letterIdxMax
}
if idx := int(cache & letterIdxMask); idx < len(letterBytes) {
b[i] = letterBytes[idx]
i--
}
cache >>= letterIdxBits
remain--
}
return b
}
+51
View File
@@ -0,0 +1,51 @@
package cli
import (
"context"
"os"
"github.com/spf13/cobra"
"github.com/crusttech/crust/internal/settings"
"github.com/crusttech/crust/system/internal/auth/external"
)
// Will perform OpenID connect auto-configuration
func ExternalAuth(ctx context.Context, rootCmd *cobra.Command, settingsService settings.Service) {
exit := func(err error) {
if err != nil {
rootCmd.Printf("Error: %v\n", err)
os.Exit(1)
} else {
os.Exit(0)
}
}
autoDiscover := &cobra.Command{
Use: "auto-discovery [name] [url]",
Short: "Auto discovers new OIDC client",
Args: cobra.ExactArgs(2),
Run: func(cmd *cobra.Command, args []string) {
var name, url = args[0], args[1]
if eas, err := external.ExternalAuthSettings(settingsService); err != nil {
exit(err)
} else if eap, err := external.RegisterNewOpenIdClient(ctx, eas, name, url); err != nil {
exit(err)
} else if vv, err := eap.MakeValueSet("openid-connect." + name); err != nil {
exit(err)
} else if err := settingsService.BulkSet(vv); err != nil {
exit(err)
}
},
}
settingsCmd := &cobra.Command{
Use: "external-auth",
Short: "External authentication",
}
settingsCmd.AddCommand(autoDiscover)
rootCmd.AddCommand(settingsCmd)
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,3 @@
ALTER TABLE `sys_user` DROP `password`;
ALTER TABLE `sys_user` DROP `satosa_id`;
ALTER TABLE `sys_credentials` ADD `last_used_at` DATETIME NULL;
-10
View File
@@ -12,8 +12,6 @@ type (
http *config.HTTP
monitor *config.Monitor
db *config.Database
oidc *config.OIDC
social *config.Social
}
)
@@ -35,12 +33,6 @@ func (c *appFlags) Validate() error {
if err := c.db.Validate(); err != nil {
return err
}
if err := c.oidc.Validate(); err != nil {
return err
}
if err := c.social.Validate(); err != nil {
return err
}
return nil
}
@@ -56,7 +48,5 @@ func Flags(prefix ...string) {
new(config.HTTP).Init(prefix...),
new(config.Monitor).Init(prefix...),
new(config.Database).Init(prefix...),
new(config.OIDC).Init(prefix...),
new(config.Social).Init(prefix...),
}
}
+15
View File
@@ -0,0 +1,15 @@
package external
import (
"log"
"github.com/crusttech/crust/internal/settings"
)
func Init(settingsService settings.Service) {
if eas, err := ExternalAuthSettings(settingsService); err != nil {
log.Printf("failed load external authentication settings: %v", err)
} else {
setupGoth(eas)
}
}
+100
View File
@@ -0,0 +1,100 @@
package external
import (
"log"
"strings"
"github.com/gorilla/sessions"
"github.com/markbates/goth"
"github.com/markbates/goth/gothic"
"github.com/markbates/goth/providers/facebook"
"github.com/markbates/goth/providers/github"
"github.com/markbates/goth/providers/gplus"
"github.com/markbates/goth/providers/linkedin"
"github.com/markbates/goth/providers/openidConnect"
)
// We're expecting that our users will be able to complete
// external auth loop in 15 minutes.
const (
gothMaxSessionStoreAge = 60 * 15 // In seconds.
)
func setupGoth(eas *externalAuthSettings) {
if eas == nil || !eas.enabled {
log.Printf("external authentication disabled (%v)", eas)
return
}
store := sessions.NewCookieStore([]byte(eas.sessionStoreSecret))
store.MaxAge(gothMaxSessionStoreAge)
store.Options.Path = "/auth/external"
store.Options.HttpOnly = true
store.Options.Secure = eas.sessionStoreSecure
gothic.Store = store
setupGothProviders(eas)
}
func setupGothProviders(eas *externalAuthSettings) {
var (
err error
scopes = []string{"email"}
)
// Purge all previously configured providers
if l := len(goth.GetProviders()); l > 0 {
log.Println("removing existing providers", l)
goth.ClearProviders()
}
var enabled uint
for _, pc := range eas.providers {
if pc.enabled {
enabled++
}
}
log.Printf("initializing external authentication providers (%d)", enabled)
for name, pc := range eas.providers {
var provider goth.Provider
if !pc.enabled {
continue
}
if strings.Index(name, "openid-connect.") == 0 {
if pc.issuerUrl == "" {
log.Printf("failed to discover %q (issuer URL is empty)", name)
continue
}
wellKnown := strings.TrimSuffix(pc.issuerUrl, "/") + "/.well-known/openid-configuration"
if provider, err = openidConnect.New(pc.key, pc.secret, pc.redirectUrl, wellKnown, scopes...); err != nil {
log.Printf("failed to discover %q (auto discovery URL: %s) OIDC provider: %v", name, wellKnown, err)
continue
} else {
provider.SetName(name)
}
} else {
switch name {
case "github":
provider = github.New(pc.key, pc.secret, pc.redirectUrl, scopes...)
case "facebook":
provider = facebook.New(pc.key, pc.secret, pc.redirectUrl, scopes...)
case "gplus":
provider = gplus.New(pc.key, pc.secret, pc.redirectUrl, scopes...)
case "linkedin":
provider = linkedin.New(pc.key, pc.secret, pc.redirectUrl, scopes...)
}
}
if provider != nil {
log.Printf("external authentication provider %q added", name)
goth.UseProviders(provider)
}
}
}
+39
View File
@@ -0,0 +1,39 @@
package external
import (
"context"
"fmt"
"github.com/crusttech/go-oidc"
)
func RegisterNewOpenIdClient(ctx context.Context, eas *externalAuthSettings, name, url string) (eap *externalAuthProvider, err error) {
var (
provider *oidc.Provider
client *oidc.Client
redirectUrl = fmt.Sprintf(eas.redirectUrl, "openid-connect."+name)
)
if provider, err = oidc.NewProvider(ctx, url); err != nil {
return
}
client, err = provider.RegisterClient(ctx, &oidc.ClientRegistration{
Name: "Crust",
RedirectURIs: []string{redirectUrl},
ResponseTypes: []string{"token id_token", "code"},
})
if err != nil {
return
}
eap = &externalAuthProvider{
redirectUrl: redirectUrl,
key: client.ID,
secret: client.Secret,
issuerUrl: url,
}
return
}
+199
View File
@@ -0,0 +1,199 @@
package external
import (
"encoding/json"
"fmt"
"os"
"strings"
"github.com/pkg/errors"
"github.com/crusttech/crust/internal/rand"
intset "github.com/crusttech/crust/internal/settings"
)
const (
settingsKeyBase = "auth.external."
settingsKeyProviders = settingsKeyBase + "providers."
settingsKeyRedirectUrl = settingsKeyBase + "redirect-url"
settingsKeySessionStoreSecret = settingsKeyBase + "session-store-secret"
settingsKeySessionStoreSecure = settingsKeyBase + "session-store-secure"
)
type (
externalAuthSettings struct {
enabled bool
redirectUrl string
sessionStoreSecret string
sessionStoreSecure bool
providers map[string]externalAuthProvider
service intset.Service
}
externalAuthProvider struct {
enabled bool
key string
secret string
redirectUrl string
issuerUrl string
}
)
func ExternalAuthProvider(kv intset.KV) (eap externalAuthProvider, err error) {
for k, v := range kv {
ld := strings.LastIndex(k, ".")
switch k[ld+1:] {
case "enabled":
err = v.Unmarshal(&eap.enabled)
case "key":
err = v.Unmarshal(&eap.key)
case "secret":
err = v.Unmarshal(&eap.secret)
case "issuer":
err = v.Unmarshal(&eap.issuerUrl)
}
if err != nil {
return
}
}
return
}
func (eap externalAuthProvider) MakeValueSet(name string) (vv intset.ValueSet, err error) {
set := func(name string, value interface{}) error {
v := &intset.Value{Name: name}
if v.Value, err = json.Marshal(value); err != nil {
return err
}
vv = append(vv, v)
return nil
}
prefix := settingsKeyProviders + name
if err = set(prefix+".enabled", eap.enabled); err != nil {
return nil, err
}
if err = set(prefix+".key", eap.key); err != nil {
return nil, err
}
if err = set(prefix+".secret", eap.secret); err != nil {
return nil, err
}
if err = set(prefix+".issuer", eap.issuerUrl); err != nil {
return nil, err
}
return vv, err
}
func ExternalAuthSettings(s intset.Service) (eas *externalAuthSettings, err error) {
// Read all settings and populate struct
settings, err := s.FindByPrefix(settingsKeyBase)
if err != nil {
return nil, errors.Wrap(err, "could not load settings for external auth provider")
}
kv := settings.KV()
eas = &externalAuthSettings{
enabled: kv.Bool(settingsKeyBase + "enabled"),
redirectUrl: kv.String(settingsKeyRedirectUrl),
sessionStoreSecret: kv.String(settingsKeySessionStoreSecret),
}
storeGenerated := func(name string, value interface{}) (err error) {
v := &intset.Value{Name: name}
if v.Value, err = json.Marshal(value); err != nil {
return
}
return s.Set(v)
}
if eas.redirectUrl == "" {
path := "/auth/external/%s/callback"
if leHost, has := os.LookupEnv("LETSENCRYPT_HOST"); has {
eas.redirectUrl = "https://" + leHost + path
} else if vHost, has := os.LookupEnv("VIRTUAL_HOST"); has {
eas.redirectUrl = "http://" + vHost + path
} else {
// Fallback to local
eas.redirectUrl = "http://system.api.local.crust.tech" + path
}
if err = storeGenerated(settingsKeyRedirectUrl, eas.redirectUrl); err != nil {
return
}
}
if eas.sessionStoreSecret == "" {
eas.sessionStoreSecret = string(rand.Bytes(64))
if err = storeGenerated(settingsKeySessionStoreSecret, eas.sessionStoreSecret); err != nil {
return
}
}
if !kv.Has(settingsKeySessionStoreSecure) {
// Assume we want to use secure store if we are accessed via HTTPS
eas.sessionStoreSecure = strings.Index(eas.redirectUrl, "https://") == 0
} else {
eas.sessionStoreSecure = kv.Bool(settingsKeySessionStoreSecure)
}
if eas.providers, err = extractProviders(eas.redirectUrl, kv); err != nil {
return nil, err
}
return
}
func extractProviders(redirectUrl string, kv intset.KV) (providers map[string]externalAuthProvider, err error) {
// Standard providers:
providers = map[string]externalAuthProvider{
"github": {},
"facebook": {},
"gplus": {},
"linkedin": {},
}
oidcKeyBase := settingsKeyProviders + "openid-connect."
for k := range kv.Filter(oidcKeyBase) {
if len(k) < len(oidcKeyBase)+2 {
// skip invalid keys
continue
}
// find next dot:
name := k[len(oidcKeyBase):]
dotPos := strings.Index(name, ".")
if dotPos > 0 {
name = name[:dotPos]
}
providers["openid-connect."+name] = externalAuthProvider{}
}
for provider := range providers {
if eap, err := ExternalAuthProvider(kv.Filter(settingsKeyProviders + provider)); err != nil {
return nil, err
} else {
if eap.enabled {
eap.redirectUrl = fmt.Sprintf(redirectUrl, provider)
}
providers[provider] = eap
}
}
return
}
+142
View File
@@ -0,0 +1,142 @@
package external
import (
"reflect"
"testing"
intset "github.com/crusttech/crust/internal/settings"
"github.com/jmoiron/sqlx/types"
)
func Test_extractProviders(t *testing.T) {
type args struct {
redirectUrl string
kv intset.KV
}
tests := []struct {
name string
args args
wantProviders map[string]externalAuthProvider
wantErr bool
}{
{
name: "Empty KV",
args: args{},
wantProviders: map[string]externalAuthProvider{
"github": externalAuthProvider{},
"linkedin": externalAuthProvider{},
"gplus": externalAuthProvider{},
"facebook": externalAuthProvider{},
},
},
{
name: "Random config",
args: args{
redirectUrl: "http://%s",
kv: intset.KV{
"auth.external.providers.openid-connect.foo.enabled": types.JSONText("true"),
"auth.external.providers.openid-connect.foo.issuer": types.JSONText(`"url"`),
"auth.external.providers.openid-connect.foo.key": types.JSONText(`"key"`),
"auth.external.providers.openid-connect.foo.secret": types.JSONText(`"secret"`),
"auth.external.providers.openid-connect.bar.enabled": types.JSONText("true"),
"auth.external.providers.openid-connect.bar.issuer": types.JSONText(`"url"`),
"auth.external.providers.openid-connect.bar.key": types.JSONText(`"key"`),
"auth.external.providers.openid-connect.bar.secret": types.JSONText(`"secret"`),
"auth.external.providers.openid-connect.baz.enabled": types.JSONText("false"),
"auth.external.providers.github.enabled": types.JSONText(`false`),
"auth.external.providers.facebook.enabled": types.JSONText(`true`),
"auth.external.providers.facebook.secret": types.JSONText(`"fb-secret"`),
},
},
wantProviders: map[string]externalAuthProvider{
"openid-connect.foo": externalAuthProvider{
enabled: true,
key: "key",
secret: "secret",
redirectUrl: "http://openid-connect.foo",
issuerUrl: "url",
},
"openid-connect.bar": externalAuthProvider{
enabled: true,
key: "key",
secret: "secret",
redirectUrl: "http://openid-connect.bar",
issuerUrl: "url",
},
"openid-connect.baz": externalAuthProvider{
enabled: false,
},
"github": externalAuthProvider{
enabled: false,
},
"linkedin": externalAuthProvider{
enabled: false,
},
"gplus": externalAuthProvider{
enabled: false,
},
"facebook": externalAuthProvider{
enabled: true,
secret: "fb-secret",
redirectUrl: "http://facebook",
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotProviders, err := extractProviders(tt.args.redirectUrl, tt.args.kv)
if (err != nil) != tt.wantErr {
t.Errorf("extractProviders() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(gotProviders, tt.wantProviders) {
t.Errorf("extractProviders() = %v, want %v", gotProviders, tt.wantProviders)
}
})
}
}
func TestExternalAuthProvider(t *testing.T) {
type args struct {
kv intset.KV
}
tests := []struct {
name string
args args
wantEap externalAuthProvider
wantErr bool
}{
{
args: args{
kv: intset.KV{
"foo.enabled": types.JSONText("true"),
"foo.issuer": types.JSONText(`"example.tld"`),
"foo.key": types.JSONText(`"key"`),
"foo.secret": types.JSONText(`"secret"`),
},
},
wantEap: externalAuthProvider{
enabled: true,
key: "key",
secret: "secret",
issuerUrl: "example.tld",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotEap, err := ExternalAuthProvider(tt.args.kv)
if (err != nil) != tt.wantErr {
t.Errorf("ExternalAuthProvider() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(gotEap, tt.wantEap) {
t.Errorf("ExternalAuthProvider() = %v, want %v", gotEap, tt.wantEap)
}
})
}
}
+11 -4
View File
@@ -15,12 +15,13 @@ type (
With(ctx context.Context, db *factory.DB) CredentialsRepository
FindByID(ID uint64) (*types.Credentials, error)
FindByCredentials(kind types.CredentialsKind, credentials string) (cc types.CredentialsSet, err error)
FindByKind(ownerID uint64, kind types.CredentialsKind) (cc types.CredentialsSet, err error)
FindByCredentials(kind, credentials string) (cc types.CredentialsSet, err error)
FindByKind(ownerID uint64, kind string) (cc types.CredentialsSet, err error)
FindByOwnerID(ownerID uint64) (cc types.CredentialsSet, err error)
Find() (cc types.CredentialsSet, err error)
Create(c *types.Credentials) (*types.Credentials, error)
Update(c *types.Credentials) (*types.Credentials, error)
DeleteByID(id uint64) error
}
@@ -59,14 +60,14 @@ func (r *credentials) FindByID(ID uint64) (*types.Credentials, error) {
return mod, isFound(r.db().Get(mod, sql, ID), mod.ID > 0, ErrCredentialsNotFound)
}
func (r *credentials) FindByCredentials(kind types.CredentialsKind, credentials string) (cc types.CredentialsSet, err error) {
func (r *credentials) FindByCredentials(kind, credentials string) (cc types.CredentialsSet, err error) {
return r.fetchSet(
fmt.Sprintf(sqlCredentialsSelect+" AND kind = ? AND credentials = ?", r.tblname),
kind,
credentials)
}
func (r *credentials) FindByKind(ownerID uint64, kind types.CredentialsKind) (cc types.CredentialsSet, err error) {
func (r *credentials) FindByKind(ownerID uint64, kind string) (cc types.CredentialsSet, err error) {
return r.fetchSet(
fmt.Sprintf(sqlCredentialsSelect+" AND rel_owner = ? AND kind = ?", r.tblname),
ownerID,
@@ -95,6 +96,12 @@ func (r *credentials) Create(c *types.Credentials) (*types.Credentials, error) {
return c, r.db().Insert(r.tblname, c)
}
func (r *credentials) Update(c *types.Credentials) (*types.Credentials, error) {
updatedAt := time.Now()
c.UpdatedAt = &updatedAt
return c, r.db().Update(r.tblname, c)
}
func (r *credentials) DeleteByID(id uint64) error {
return r.updateColumnByID(r.tblname, "deleted_at", time.Now(), id)
}
@@ -29,9 +29,9 @@ func TestCredentials(t *testing.T) {
db.Exec("DELETE FROM sys_credentials WHERE 1=1")
cc := types.CredentialsSet{
&types.Credentials{OwnerID: 10000, Kind: types.CredentialsKindLinkedin, Credentials: "linkedin-profile-id"},
&types.Credentials{OwnerID: 10000, Kind: types.CredentialsKindGPlus, Credentials: "gplus-profile-id"},
&types.Credentials{OwnerID: 20000, Kind: types.CredentialsKindFacebook, Credentials: "facebook-profile-id"},
&types.Credentials{OwnerID: 10000, Kind: "li", Credentials: "linkedin-profile-id"},
&types.Credentials{OwnerID: 10000, Kind: "g+", Credentials: "gplus-profile-id"},
&types.Credentials{OwnerID: 20000, Kind: "fb", Credentials: "facebook-profile-id"},
}
for _, c := range cc {
+15 -18
View File
@@ -38,7 +38,6 @@ func (m *MockCredentialsRepository) EXPECT() *MockCredentialsRepositoryMockRecor
// With mocks base method
func (m *MockCredentialsRepository) With(ctx context.Context, db *factory.DB) repository.CredentialsRepository {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "With", ctx, db)
ret0, _ := ret[0].(repository.CredentialsRepository)
return ret0
@@ -46,13 +45,11 @@ func (m *MockCredentialsRepository) With(ctx context.Context, db *factory.DB) re
// With indicates an expected call of With
func (mr *MockCredentialsRepositoryMockRecorder) With(ctx, db interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "With", reflect.TypeOf((*MockCredentialsRepository)(nil).With), ctx, db)
}
// FindByID mocks base method
func (m *MockCredentialsRepository) FindByID(ID uint64) (*types.Credentials, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "FindByID", ID)
ret0, _ := ret[0].(*types.Credentials)
ret1, _ := ret[1].(error)
@@ -61,13 +58,11 @@ func (m *MockCredentialsRepository) FindByID(ID uint64) (*types.Credentials, err
// FindByID indicates an expected call of FindByID
func (mr *MockCredentialsRepositoryMockRecorder) FindByID(ID interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindByID", reflect.TypeOf((*MockCredentialsRepository)(nil).FindByID), ID)
}
// FindByCredentials mocks base method
func (m *MockCredentialsRepository) FindByCredentials(kind types.CredentialsKind, credentials string) (types.CredentialsSet, error) {
m.ctrl.T.Helper()
func (m *MockCredentialsRepository) FindByCredentials(kind, credentials string) (types.CredentialsSet, error) {
ret := m.ctrl.Call(m, "FindByCredentials", kind, credentials)
ret0, _ := ret[0].(types.CredentialsSet)
ret1, _ := ret[1].(error)
@@ -76,13 +71,11 @@ func (m *MockCredentialsRepository) FindByCredentials(kind types.CredentialsKind
// FindByCredentials indicates an expected call of FindByCredentials
func (mr *MockCredentialsRepositoryMockRecorder) FindByCredentials(kind, credentials interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindByCredentials", reflect.TypeOf((*MockCredentialsRepository)(nil).FindByCredentials), kind, credentials)
}
// FindByKind mocks base method
func (m *MockCredentialsRepository) FindByKind(ownerID uint64, kind types.CredentialsKind) (types.CredentialsSet, error) {
m.ctrl.T.Helper()
func (m *MockCredentialsRepository) FindByKind(ownerID uint64, kind string) (types.CredentialsSet, error) {
ret := m.ctrl.Call(m, "FindByKind", ownerID, kind)
ret0, _ := ret[0].(types.CredentialsSet)
ret1, _ := ret[1].(error)
@@ -91,13 +84,11 @@ func (m *MockCredentialsRepository) FindByKind(ownerID uint64, kind types.Creden
// FindByKind indicates an expected call of FindByKind
func (mr *MockCredentialsRepositoryMockRecorder) FindByKind(ownerID, kind interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindByKind", reflect.TypeOf((*MockCredentialsRepository)(nil).FindByKind), ownerID, kind)
}
// FindByOwnerID mocks base method
func (m *MockCredentialsRepository) FindByOwnerID(ownerID uint64) (types.CredentialsSet, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "FindByOwnerID", ownerID)
ret0, _ := ret[0].(types.CredentialsSet)
ret1, _ := ret[1].(error)
@@ -106,13 +97,11 @@ func (m *MockCredentialsRepository) FindByOwnerID(ownerID uint64) (types.Credent
// FindByOwnerID indicates an expected call of FindByOwnerID
func (mr *MockCredentialsRepositoryMockRecorder) FindByOwnerID(ownerID interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindByOwnerID", reflect.TypeOf((*MockCredentialsRepository)(nil).FindByOwnerID), ownerID)
}
// Find mocks base method
func (m *MockCredentialsRepository) Find() (types.CredentialsSet, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Find")
ret0, _ := ret[0].(types.CredentialsSet)
ret1, _ := ret[1].(error)
@@ -121,13 +110,11 @@ func (m *MockCredentialsRepository) Find() (types.CredentialsSet, error) {
// Find indicates an expected call of Find
func (mr *MockCredentialsRepositoryMockRecorder) Find() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Find", reflect.TypeOf((*MockCredentialsRepository)(nil).Find))
}
// Create mocks base method
func (m *MockCredentialsRepository) Create(c *types.Credentials) (*types.Credentials, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Create", c)
ret0, _ := ret[0].(*types.Credentials)
ret1, _ := ret[1].(error)
@@ -136,13 +123,24 @@ func (m *MockCredentialsRepository) Create(c *types.Credentials) (*types.Credent
// Create indicates an expected call of Create
func (mr *MockCredentialsRepositoryMockRecorder) Create(c interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockCredentialsRepository)(nil).Create), c)
}
// Update mocks base method
func (m *MockCredentialsRepository) Update(c *types.Credentials) (*types.Credentials, error) {
ret := m.ctrl.Call(m, "Update", c)
ret0, _ := ret[0].(*types.Credentials)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// Update indicates an expected call of Update
func (mr *MockCredentialsRepositoryMockRecorder) Update(c interface{}) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockCredentialsRepository)(nil).Update), c)
}
// DeleteByID mocks base method
func (m *MockCredentialsRepository) DeleteByID(id uint64) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "DeleteByID", id)
ret0, _ := ret[0].(error)
return ret0
@@ -150,6 +148,5 @@ func (m *MockCredentialsRepository) DeleteByID(id uint64) error {
// DeleteByID indicates an expected call of DeleteByID
func (mr *MockCredentialsRepositoryMockRecorder) DeleteByID(id interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteByID", reflect.TypeOf((*MockCredentialsRepository)(nil).DeleteByID), id)
}
-39
View File
@@ -39,7 +39,6 @@ func (m *MockUserRepository) EXPECT() *MockUserRepositoryMockRecorder {
// With mocks base method
func (m *MockUserRepository) With(ctx context.Context, db *factory.DB) repository.UserRepository {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "With", ctx, db)
ret0, _ := ret[0].(repository.UserRepository)
return ret0
@@ -47,13 +46,11 @@ func (m *MockUserRepository) With(ctx context.Context, db *factory.DB) repositor
// With indicates an expected call of With
func (mr *MockUserRepositoryMockRecorder) With(ctx, db interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "With", reflect.TypeOf((*MockUserRepository)(nil).With), ctx, db)
}
// FindByEmail mocks base method
func (m *MockUserRepository) FindByEmail(email string) (*types.User, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "FindByEmail", email)
ret0, _ := ret[0].(*types.User)
ret1, _ := ret[1].(error)
@@ -62,13 +59,11 @@ func (m *MockUserRepository) FindByEmail(email string) (*types.User, error) {
// FindByEmail indicates an expected call of FindByEmail
func (mr *MockUserRepositoryMockRecorder) FindByEmail(email interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindByEmail", reflect.TypeOf((*MockUserRepository)(nil).FindByEmail), email)
}
// FindByUsername mocks base method
func (m *MockUserRepository) FindByUsername(username string) (*types.User, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "FindByUsername", username)
ret0, _ := ret[0].(*types.User)
ret1, _ := ret[1].(error)
@@ -77,13 +72,11 @@ func (m *MockUserRepository) FindByUsername(username string) (*types.User, error
// FindByUsername indicates an expected call of FindByUsername
func (mr *MockUserRepositoryMockRecorder) FindByUsername(username interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindByUsername", reflect.TypeOf((*MockUserRepository)(nil).FindByUsername), username)
}
// FindByID mocks base method
func (m *MockUserRepository) FindByID(id uint64) (*types.User, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "FindByID", id)
ret0, _ := ret[0].(*types.User)
ret1, _ := ret[1].(error)
@@ -92,13 +85,11 @@ func (m *MockUserRepository) FindByID(id uint64) (*types.User, error) {
// FindByID indicates an expected call of FindByID
func (mr *MockUserRepositoryMockRecorder) FindByID(id interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindByID", reflect.TypeOf((*MockUserRepository)(nil).FindByID), id)
}
// FindByIDs mocks base method
func (m *MockUserRepository) FindByIDs(id ...uint64) (types.UserSet, error) {
m.ctrl.T.Helper()
varargs := []interface{}{}
for _, a := range id {
varargs = append(varargs, a)
@@ -111,28 +102,11 @@ func (m *MockUserRepository) FindByIDs(id ...uint64) (types.UserSet, error) {
// FindByIDs indicates an expected call of FindByIDs
func (mr *MockUserRepositoryMockRecorder) FindByIDs(id ...interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindByIDs", reflect.TypeOf((*MockUserRepository)(nil).FindByIDs), id...)
}
// FindBySatosaID mocks base method
func (m *MockUserRepository) FindBySatosaID(id string) (*types.User, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "FindBySatosaID", id)
ret0, _ := ret[0].(*types.User)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// FindBySatosaID indicates an expected call of FindBySatosaID
func (mr *MockUserRepositoryMockRecorder) FindBySatosaID(id interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindBySatosaID", reflect.TypeOf((*MockUserRepository)(nil).FindBySatosaID), id)
}
// Find mocks base method
func (m *MockUserRepository) Find(filter *types.UserFilter) ([]*types.User, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Find", filter)
ret0, _ := ret[0].([]*types.User)
ret1, _ := ret[1].(error)
@@ -141,13 +115,11 @@ func (m *MockUserRepository) Find(filter *types.UserFilter) ([]*types.User, erro
// Find indicates an expected call of Find
func (mr *MockUserRepositoryMockRecorder) Find(filter interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Find", reflect.TypeOf((*MockUserRepository)(nil).Find), filter)
}
// Create mocks base method
func (m *MockUserRepository) Create(mod *types.User) (*types.User, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Create", mod)
ret0, _ := ret[0].(*types.User)
ret1, _ := ret[1].(error)
@@ -156,13 +128,11 @@ func (m *MockUserRepository) Create(mod *types.User) (*types.User, error) {
// Create indicates an expected call of Create
func (mr *MockUserRepositoryMockRecorder) Create(mod interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockUserRepository)(nil).Create), mod)
}
// Update mocks base method
func (m *MockUserRepository) Update(mod *types.User) (*types.User, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Update", mod)
ret0, _ := ret[0].(*types.User)
ret1, _ := ret[1].(error)
@@ -171,13 +141,11 @@ func (m *MockUserRepository) Update(mod *types.User) (*types.User, error) {
// Update indicates an expected call of Update
func (mr *MockUserRepositoryMockRecorder) Update(mod interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockUserRepository)(nil).Update), mod)
}
// BindAvatar mocks base method
func (m *MockUserRepository) BindAvatar(user *types.User, avatar io.Reader) (*types.User, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "BindAvatar", user, avatar)
ret0, _ := ret[0].(*types.User)
ret1, _ := ret[1].(error)
@@ -186,13 +154,11 @@ func (m *MockUserRepository) BindAvatar(user *types.User, avatar io.Reader) (*ty
// BindAvatar indicates an expected call of BindAvatar
func (mr *MockUserRepositoryMockRecorder) BindAvatar(user, avatar interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BindAvatar", reflect.TypeOf((*MockUserRepository)(nil).BindAvatar), user, avatar)
}
// SuspendByID mocks base method
func (m *MockUserRepository) SuspendByID(id uint64) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "SuspendByID", id)
ret0, _ := ret[0].(error)
return ret0
@@ -200,13 +166,11 @@ func (m *MockUserRepository) SuspendByID(id uint64) error {
// SuspendByID indicates an expected call of SuspendByID
func (mr *MockUserRepositoryMockRecorder) SuspendByID(id interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SuspendByID", reflect.TypeOf((*MockUserRepository)(nil).SuspendByID), id)
}
// UnsuspendByID mocks base method
func (m *MockUserRepository) UnsuspendByID(id uint64) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UnsuspendByID", id)
ret0, _ := ret[0].(error)
return ret0
@@ -214,13 +178,11 @@ func (m *MockUserRepository) UnsuspendByID(id uint64) error {
// UnsuspendByID indicates an expected call of UnsuspendByID
func (mr *MockUserRepositoryMockRecorder) UnsuspendByID(id interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UnsuspendByID", reflect.TypeOf((*MockUserRepository)(nil).UnsuspendByID), id)
}
// DeleteByID mocks base method
func (m *MockUserRepository) DeleteByID(id uint64) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "DeleteByID", id)
ret0, _ := ret[0].(error)
return ret0
@@ -228,6 +190,5 @@ func (m *MockUserRepository) DeleteByID(id uint64) error {
// DeleteByID indicates an expected call of DeleteByID
func (mr *MockUserRepositoryMockRecorder) DeleteByID(id interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteByID", reflect.TypeOf((*MockUserRepository)(nil).DeleteByID), id)
}
+6
View File
@@ -160,6 +160,12 @@ func (r *role) Reset() error {
return err
}
// Remove all rules for both roles
sql = "DELETE FROM sys_rules WHERE rel_role IN (1, 2)"
if _, err = r.db().Exec(sql); err != nil {
return err
}
// Value: Allow (2), Deny (1), Inherit(0)
sql = `REPLACE INTO sys_rules (rel_role, resource, operation, value) VALUES
-- Everyone
+2 -10
View File
@@ -20,7 +20,6 @@ type (
FindByUsername(username string) (*types.User, error)
FindByID(id uint64) (*types.User, error)
FindByIDs(id ...uint64) (types.UserSet, error)
FindBySatosaID(id string) (*types.User, error)
Find(filter *types.UserFilter) ([]*types.User, error)
Create(mod *types.User) (*types.User, error)
@@ -42,8 +41,8 @@ type (
)
const (
sqlUserColumns = "id, email, username, password, name, handle, " +
"meta, satosa_id, rel_organisation, " +
sqlUserColumns = "id, email, username, name, handle, " +
"meta, rel_organisation, " +
"created_at, updated_at, suspended_at, deleted_at"
sqlUserScope = "suspended_at IS NULL AND deleted_at IS NULL"
sqlUserSelect = "SELECT " + sqlUserColumns + " FROM %s WHERE " + sqlUserScope
@@ -69,13 +68,6 @@ func (r *user) FindByUsername(username string) (*types.User, error) {
return mod, isFound(r.db().Get(mod, sql, username), mod.ID > 0, ErrUserNotFound)
}
func (r *user) FindBySatosaID(satosaID string) (*types.User, error) {
sql := fmt.Sprintf(sqlUserSelect, r.users) + " AND satosa_id = ?"
mod := &types.User{}
return mod, isFound(r.db().Get(mod, sql, satosaID), mod.ID > 0, ErrUserNotFound)
}
func (r *user) FindByEmail(email string) (*types.User, error) {
sql := fmt.Sprintf(sqlUserSelect, r.users) + " AND email = ?"
mod := &types.User{}
-1
View File
@@ -27,7 +27,6 @@ func TestUser(t *testing.T) {
user := &types.User{
Name: "John User Doe",
Username: "johndoe",
SatosaID: "1234",
Meta: &types.UserMeta{
Avatar: "123",
},
+51 -28
View File
@@ -3,6 +3,7 @@ package service
import (
"context"
"log"
"time"
"github.com/markbates/goth"
"github.com/pkg/errors"
@@ -23,7 +24,7 @@ type (
AuthService interface {
With(ctx context.Context) AuthService
Social(profile goth.User) (*types.User, error)
External(profile goth.User) (*types.User, error)
CheckPassword(username, password string) (*types.User, error)
ChangePassword(user *types.User, password string) error
@@ -50,14 +51,11 @@ func (svc *auth) With(ctx context.Context) AuthService {
// Social user verifies existance by using email value from social profile and creates user if needed
//
// It does not update user's info
func (svc *auth) Social(profile goth.User) (u *types.User, err error) {
var kind types.CredentialsKind
func (svc *auth) External(profile goth.User) (u *types.User, err error) {
var lastUsedAt = time.Now()
switch profile.Provider {
case "facebook", "gplus", "github", "linkedin":
kind = types.CredentialsKind(profile.Provider)
default:
return nil, errors.New("Unsupported provider")
if _, err := goth.GetProvider(profile.Provider); err != nil {
return nil, err
}
if profile.Email == "" {
@@ -66,7 +64,7 @@ func (svc *auth) Social(profile goth.User) (u *types.User, err error) {
return u, svc.db.Transaction(func() error {
var c *types.Credentials
if cc, err := svc.credentials.FindByCredentials(kind, profile.UserID); err == nil {
if cc, err := svc.credentials.FindByCredentials(profile.Provider, profile.UserID); err == nil {
// Credentials found, load user
for _, c := range cc {
if !c.Valid() {
@@ -110,6 +108,8 @@ func (svc *auth) Social(profile goth.User) (u *types.User, err error) {
findByEmail:
// Find user via his email
if u, err = svc.users.FindByEmail(profile.Email); err == repository.ErrUserNotFound {
// @todo check if it is ok to auto-create a user here
// In case we do not have this email, create a new user
u = &types.User{
Email: profile.Email,
@@ -119,13 +119,35 @@ func (svc *auth) Social(profile goth.User) (u *types.User, err error) {
}
if u, err = svc.users.Create(u); err != nil {
return err
return errors.Wrap(err, "could not create user after successful social auth")
}
log.Printf("Created new user after successful social auth (%v, %v)", u.ID, u.Email)
// Owner created
return nil
} else if err != nil {
return err
} else if !u.Valid() {
return errors.Errorf(
"Social login to an invalid/suspended user (user ID: %v)",
u.ID,
)
} else {
log.Printf(
"Autheticated user (%v, %v) via %s, existing user",
u.ID,
u.Email,
profile.Provider,
)
}
if c == nil {
c = &types.Credentials{
Kind: kind,
Kind: profile.Provider,
OwnerID: u.ID,
Credentials: profile.UserID,
LastUsedAt: &lastUsedAt,
}
if !profile.ExpiresAt.IsZero() {
@@ -138,31 +160,32 @@ func (svc *auth) Social(profile goth.User) (u *types.User, err error) {
}
log.Printf(
"Autheticated user (%v, %v) via %s, created user and credentials (%v)",
"Creating new credential entry (%v, %v) for exisintg user (%v, %v)",
c.ID,
profile.Provider,
u.ID,
u.Email,
profile.Provider,
c.ID,
)
} else {
if !profile.ExpiresAt.IsZero() {
// Copy expiration date when provided
c.ExpiresAt = &profile.ExpiresAt
}
// Owner created
return nil
} else if err != nil {
return err
} else if !u.Valid() {
return errors.Errorf(
"Social login to an invalid/suspended user (user ID: %v)",
c.LastUsedAt = &lastUsedAt
if c, err = svc.credentials.Update(c); err != nil {
return err
}
log.Printf(
"Updating credential entry (%v, %v) for exisintg user (%v, %v)",
c.ID,
profile.Provider,
u.ID,
u.Email,
)
}
log.Printf(
"Autheticated user (%v, %v) via %s, existing user",
u.ID,
u.Email,
profile.Provider,
)
// Owner loaded, carry on.
return nil
})
+3 -3
View File
@@ -24,7 +24,7 @@ func TestSocialSigninWithExistingCredentials(t *testing.T) {
crdRpoMock := repomock.NewMockCredentialsRepository(mockCtrl)
crdRpoMock.EXPECT().
FindByCredentials(types.CredentialsKindGPlus, p.UserID).
FindByCredentials("g+", p.UserID).
Times(1).
Return(types.CredentialsSet{c}, nil)
@@ -54,12 +54,12 @@ func TestSocialSigninWithNewUserCredentials(t *testing.T) {
crdRpoMock := repomock.NewMockCredentialsRepository(mockCtrl)
crdRpoMock.EXPECT().
FindByCredentials(types.CredentialsKindGPlus, p.UserID).
FindByCredentials("g+", p.UserID).
Times(1).
Return(types.CredentialsSet{}, nil)
crdRpoMock.EXPECT().
Create(&types.Credentials{Kind: types.CredentialsKindGPlus, OwnerID: u.ID, Credentials: p.UserID}).
Create(&types.Credentials{Kind: "g+", OwnerID: u.ID, Credentials: p.UserID}).
Times(1).
Return(c, nil)
+1 -32
View File
@@ -37,8 +37,6 @@ type (
FindByIDs(id ...uint64) (types.UserSet, error)
Find(filter *types.UserFilter) (types.UserSet, error)
FindOrCreate(*types.User) (*types.User, error)
Create(input *types.User) (*types.User, error)
Update(mod *types.User) (*types.User, error)
@@ -85,36 +83,6 @@ func (svc *user) Find(filter *types.UserFilter) (types.UserSet, error) {
return svc.user.Find(filter)
}
// FindOrCreate finds existing or creates new user wrapped in db transaction
func (svc *user) FindOrCreate(user *types.User) (out *types.User, err error) {
return out, svc.db.Transaction(func() error {
if len(user.SatosaID) != uuidLength {
// @todo uuid format check
return errors.Errorf("Invalid UUID value (%v) for SATOSA ID", user.SatosaID)
}
out, err = svc.user.FindBySatosaID(user.SatosaID)
if err == repository.ErrUserNotFound {
// @todo do we allow autocreation of nonexisting users?
out, err = svc.user.Create(user)
return err
}
if err != nil {
// FindBySatosaID error
return err
}
out, err = svc.user.Update(out)
if err != nil {
return err
}
return nil
})
}
func (svc *user) Create(input *types.User) (out *types.User, err error) {
return out, svc.db.Transaction(func() error {
if !svc.prm.CanCreateUser() {
@@ -124,6 +92,7 @@ func (svc *user) Create(input *types.User) (out *types.User, err error) {
if out, err = svc.user.Create(input); err != nil {
return err
}
return nil
})
}
+19 -17
View File
@@ -79,7 +79,7 @@ func (ctrl *Auth) Handlers(jwtEncoder auth.TokenEncoder) *handlers.Auth {
}
h.Login = func(w http.ResponseWriter, r *http.Request) {
params := request.NewAuthLogin()
ctx := r.Context()
// ctx := r.Context()
// parse request to fill parameters
err := params.Fill(r)
@@ -88,26 +88,28 @@ func (ctrl *Auth) Handlers(jwtEncoder auth.TokenEncoder) *handlers.Auth {
return
}
userSvc := service.User(ctx)
// userSvc := service.User(ctx)
// check email and username for login
user, err := userSvc.FindByEmail(params.Username)
if err != nil {
user, err = userSvc.FindByUsername(params.Username)
}
// can't find user
if err != nil {
resputil.JSON(w, err)
return
}
// @todo disabled until we migrate to local users through credentials
// user, err := userSvc.FindByEmail(params.Username)
// if err != nil {
// user, err = userSvc.FindByUsername(params.Username)
// }
//
// // can't find user
// if err != nil {
// resputil.JSON(w, err)
// return
// }
// validate password
if user.ValidatePassword(params.Password) {
jwtEncoder.SetCookie(w, r, user)
resputil.JSON(w, user, err)
return
}
// @todo disabled until we migrate to local users through credentials
// if user.ValidatePassword(params.Password) {
// jwtEncoder.SetCookie(w, r, user)
// resputil.JSON(w, user, err)
// return
// }
resputil.JSON(w, errors.New("Password doesn't match"))
}
+140
View File
@@ -0,0 +1,140 @@
package rest
import (
"context"
"fmt"
"log"
"net/http"
"strings"
"time"
"github.com/go-chi/chi"
"github.com/markbates/goth"
"github.com/markbates/goth/gothic"
"github.com/titpetric/factory/resputil"
"github.com/crusttech/crust/internal/auth"
"github.com/crusttech/crust/system/internal/service"
)
type (
ExternalAuth struct {
auth service.AuthService
jwtEncoder auth.TokenEncoder
}
)
const (
externalAuthBaseUrl = "/auth/external"
)
func NewSocial(jwtEncoder auth.TokenEncoder) *ExternalAuth {
return &ExternalAuth{
auth: service.DefaultAuth,
jwtEncoder: jwtEncoder,
}
}
func (ctrl *ExternalAuth) MountRoutes(r chi.Router) {
// Make sure we're backwards compatible and redirect /oidc to /auth/external/openid-connect-didmos2
r.Get("/oidc", func(w http.ResponseWriter, req *http.Request) {
http.Redirect(w, req, externalAuthBaseUrl+"/openid-connect-didmos2", http.StatusMovedPermanently)
})
// Copy provider from path (Chi URL param) to request context and return it
copyProviderToContext := func(r *http.Request) *http.Request {
return r.WithContext(context.WithValue(r.Context(), "provider", chi.URLParam(r, "provider")))
}
r.Route(externalAuthBaseUrl+"/{provider}", func(r chi.Router) {
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
r = copyProviderToContext(r)
// Always set redir cookie, even if not requested. If param is empty, cookie is removed
ctrl.setSessionCookie(w, r, "redir", r.URL.Query().Get("redir"))
// try to get the user without re-authenticating
if user, err := gothic.CompleteUserAuth(w, r); err != nil {
gothic.BeginAuthHandler(w, r)
} else {
// We've successfully singed-in through 3rd party auth
ctrl.handleSuccessfulAuth(w, r, user)
}
})
r.Get("/callback", func(w http.ResponseWriter, r *http.Request) {
r = copyProviderToContext(r)
if user, err := gothic.CompleteUserAuth(w, r); err != nil {
log.Printf("Failed to complete user auth: %v", err)
ctrl.handleFailedCallback(w, r, err)
} else {
ctrl.handleSuccessfulAuth(w, r, user)
}
})
r.Get("/logout", func(w http.ResponseWriter, r *http.Request) {
if err := gothic.Logout(w, r); err != nil {
log.Printf("Failed to external logout: %v", err)
}
w.Header().Set("Location", "/")
w.WriteHeader(http.StatusTemporaryRedirect)
})
})
}
func (ctrl *ExternalAuth) handleFailedCallback(w http.ResponseWriter, r *http.Request, err error) {
provider := chi.URLParam(r, "provider")
if strings.Contains(err.Error(), "Error processing your OAuth request: Invalid oauth_verifier parameter") {
// Just take user through the same loop again
w.Header().Set("Location", externalAuthBaseUrl+"/"+provider)
w.WriteHeader(http.StatusSeeOther)
return
}
fmt.Fprintf(w, "SSO Error: %v", err.Error())
w.WriteHeader(http.StatusOK)
}
// Handles authentication via external auth providers of
// unknown an user + appending authentication on external providers
// to a current user
func (ctrl *ExternalAuth) handleSuccessfulAuth(w http.ResponseWriter, r *http.Request, cred goth.User) {
log.Printf("Successful external login: %v", cred)
if u, err := ctrl.auth.With(r.Context()).External(cred); err != nil {
resputil.JSON(w, err)
} else {
ctrl.jwtEncoder.SetCookie(w, r, u)
if c, err := r.Cookie("redir"); c != nil && err == nil {
ctrl.setSessionCookie(w, r, "redir", "")
w.Header().Set("Location", c.Value)
w.WriteHeader(http.StatusSeeOther)
}
resputil.JSON(w, u, err)
}
}
// Extracts and authenticates JWT from context, validates claims
func (ctrl *ExternalAuth) setSessionCookie(w http.ResponseWriter, r *http.Request, name, value string) {
cookie := &http.Cookie{
Name: name,
Secure: r.URL.Scheme == "https",
Domain: r.URL.Hostname(),
Path: externalAuthBaseUrl,
}
if value == "" {
cookie.Expires = time.Unix(0, 0)
} else {
cookie.Value = value
}
http.SetCookie(w, cookie)
}
-221
View File
@@ -1,221 +0,0 @@
package rest
import (
"context"
"log"
"math/rand"
"net/http"
"strconv"
"time"
"github.com/pkg/errors"
"github.com/titpetric/factory/resputil"
"golang.org/x/oauth2"
"github.com/crusttech/go-oidc"
"github.com/crusttech/crust/internal/auth"
"github.com/crusttech/crust/internal/config"
"github.com/crusttech/crust/system/internal/repository"
"github.com/crusttech/crust/system/internal/service"
"github.com/crusttech/crust/system/types"
)
const DB_SETTINGS_KEY_OIDC_CLIENT = "oidc-client"
type (
openIdConnect struct {
provider *oidc.Provider
verifier *oidc.IDTokenVerifier
config oauth2.Config
appURL string
stateCookieExpiry int64
stateCookieDomain string
userService service.UserService
jwt auth.TokenEncoder
}
oidcProfile struct {
Email string `json:"email"`
Name string `json:"name"`
Sub string `json:"sub"`
}
)
const openIdConnectStateCookie = "oidc-state"
// Sets-up OIDC connection (issuer discovery, client registration)
//
// Client registration is done when no cfg.ClientID is provided.
func OpenIdConnect(ctx context.Context, cfg *config.OIDC, usvc service.UserService, jwt auth.TokenEncoder, settings repository.Settings) (c *openIdConnect, err error) {
c = &openIdConnect{
appURL: cfg.AppURL,
stateCookieExpiry: cfg.StateCookieExpiry,
stateCookieDomain: cfg.StateCookieDomain,
userService: usvc,
jwt: jwt,
}
// Allow 5 seconds for issuer discovery process
c.provider, err = oidc.NewProvider(ctx, cfg.Issuer)
if err != nil {
return nil, err
}
if len(cfg.ClientID) > 0 {
// System is configured with fixed OIDC client ID (probably through AUTH_OIDC_CLIENT_ID)
// Construct oauth2 config from provided configuration
c.config = oauth2.Config{
ClientID: cfg.ClientID,
ClientSecret: cfg.ClientSecret,
RedirectURL: cfg.RedirectURL,
// Discovery returns the OAuth2 endpoints.
Endpoint: c.provider.Endpoint(),
}
} else {
client := &oidc.Client{}
if found, err := settings.Get(DB_SETTINGS_KEY_OIDC_CLIENT, client); err != nil {
return nil, errors.Wrap(err, "could not load oidc client settings from the database")
} else if !found || client == nil {
log.Printf("OIDC client not found, registering new")
// Perform dynamic client registration
client, err = c.provider.RegisterClient(ctx, &oidc.ClientRegistration{
Name: "Crust",
RedirectURIs: []string{cfg.RedirectURL},
ResponseTypes: []string{"token id_token", "code"},
})
if err != nil {
return nil, errors.Wrap(err, "could not register OIDC client")
}
if err := settings.Set(DB_SETTINGS_KEY_OIDC_CLIENT, client); err != nil {
return nil, errors.Wrap(err, "could not store oidc client settings from the database")
}
}
c.config = c.provider.OAuth2Config(client)
}
c.config.Scopes = []string{
oidc.ScopeOpenID,
"email",
"profile",
"address",
"phone_number",
}
c.verifier = c.provider.Verifier(&oidc.Config{ClientID: c.config.ClientID})
return
}
// Redirects user to the issuer's login screen
func (c *openIdConnect) HandleRedirect(w http.ResponseWriter, r *http.Request) {
// @todo sure we can improve this...
rand.Seed(4321)
var state = strconv.FormatInt(rand.Int63(), 10)
// Store state to cookie as well
c.setStateCookie(w, r, state)
http.Redirect(w, r, c.config.AuthCodeURL(state), http.StatusFound)
}
// Handles callback from issuer
//
// If everything goes well (scope & token verification) it reads issued claims,
// creates Crust JWT and stores it in a cookie.
//
// @todo All failed responses must redirect to appURL as well + some error code that will be displayed on the client
func (c *openIdConnect) HandleOAuth2Callback(w http.ResponseWriter, r *http.Request) {
var ctx = r.Context()
if !c.stateCheck(r) {
resputil.JSON(w, "State check failed")
return
}
c.setStateCookie(w, r, "") // remove state cookie
oauth2Token, err := c.config.Exchange(ctx, r.URL.Query().Get("code"))
if err != nil {
resputil.JSON(w, err)
return
}
// Extract the ID Token from OAuth2 token.
rawIDToken, ok := oauth2Token.Extra("id_token").(string)
if !ok {
resputil.JSON(w, err)
return
}
// Parse and verify ID Token payload.
_, err = c.verifier.Verify(ctx, rawIDToken)
if err != nil {
resputil.JSON(w, err)
return
}
u, err := c.provider.UserInfo(ctx, oauth2.StaticTokenSource(oauth2Token))
if err != nil {
resputil.JSON(w, err)
return
}
p := &oidcProfile{}
u.Claims(p)
var user = &types.User{
SatosaID: p.Sub,
Email: p.Email,
Name: p.Name,
}
if user, err = c.userService.With(ctx).FindOrCreate(user); err != nil {
resputil.JSON(w, err)
return
} else {
c.jwt.SetCookie(w, r, user)
}
http.Redirect(w, r, c.appURL, http.StatusSeeOther)
}
func (c *openIdConnect) stateCheck(r *http.Request) bool {
if cState, err := r.Cookie(openIdConnectStateCookie); err == nil {
rState := r.URL.Query().Get("state")
return len(rState) > 0 && cState.Value == rState
}
return false
}
// Sets state cookie
func (c *openIdConnect) setStateCookie(w http.ResponseWriter, r *http.Request, value string) {
var maxAge int
if len(value) == 0 {
// When empty string for a value is received,
// set maxAge to -1. That will effectively delete the cookie
maxAge = -1
}
// Store state to cookie as well
http.SetCookie(w, &http.Cookie{
Name: openIdConnectStateCookie,
Value: value,
Expires: time.Now().Add(time.Duration(c.stateCookieExpiry) * time.Minute),
MaxAge: maxAge,
HttpOnly: true,
Secure: r.URL.Scheme == "https",
Path: "/oidc",
Domain: c.stateCookieDomain,
})
}
+2 -29
View File
@@ -1,43 +1,16 @@
package rest
import (
"context"
"log"
"github.com/go-chi/chi"
"github.com/crusttech/crust/internal/auth"
"github.com/crusttech/crust/internal/config"
"github.com/crusttech/crust/system/internal/repository"
"github.com/crusttech/crust/system/internal/service"
"github.com/crusttech/crust/system/rest/handlers"
)
func MountRoutes(oidcConfig *config.OIDC, socialConfig *config.Social, jwtEncoder auth.TokenEncoder) func(chi.Router) {
var err error
var userSvc = service.User(context.Background())
var ctx = context.Background()
var oidc *openIdConnect
if oidcConfig.Enabled {
oidc, err = OpenIdConnect(ctx, oidcConfig, userSvc, jwtEncoder, repository.NewSettings(ctx, repository.DB(ctx)))
if err != nil {
log.Println("Could not initialize OIDC:", err.Error())
}
} else {
log.Println("OIDC is disabled")
}
func MountRoutes(jwtEncoder auth.TokenEncoder) func(chi.Router) {
// Initialize handers & controllers.
return func(r chi.Router) {
if oidcConfig.Enabled && oidc != nil {
r.Route("/oidc", func(r chi.Router) {
r.Get("/", oidc.HandleRedirect)
r.Get("/callback", oidc.HandleOAuth2Callback)
})
}
NewSocial(socialConfig, jwtEncoder).MountRoutes(r)
NewSocial(jwtEncoder).MountRoutes(r)
// Provide raw `/auth` handlers
Auth{}.New().Handlers(jwtEncoder).MountRoutes(r)
-177
View File
@@ -1,177 +0,0 @@
package rest
import (
"context"
"fmt"
"log"
"net/http"
"os"
"strings"
"time"
"github.com/go-chi/chi"
"github.com/gorilla/sessions"
"github.com/markbates/goth"
"github.com/markbates/goth/gothic"
"github.com/markbates/goth/providers/facebook"
"github.com/markbates/goth/providers/github"
"github.com/markbates/goth/providers/gplus"
"github.com/markbates/goth/providers/linkedin"
"github.com/titpetric/factory/resputil"
"github.com/crusttech/crust/internal/auth"
"github.com/crusttech/crust/internal/config"
"github.com/crusttech/crust/system/internal/service"
)
type (
Social struct {
auth service.AuthService
config *config.Social
jwtEncoder auth.TokenEncoder
}
)
func NewSocial(config *config.Social, jwtEncoder auth.TokenEncoder) *Social {
return &Social{
auth: service.DefaultAuth,
config: config,
jwtEncoder: jwtEncoder,
}
}
func (ctrl *Social) MountRoutes(r chi.Router) {
store := sessions.NewCookieStore([]byte(ctrl.config.SessionStoreSecret))
store.MaxAge(ctrl.config.SessionStoreExpiry)
store.Options.Path = "/social"
store.Options.HttpOnly = true
store.Options.Secure = false // @todo
gothic.Store = store
getProviderConfig := func(provider string) (key, secret string, has bool) {
key, keyOk := os.LookupEnv("AUTH_SOCIAL_" + provider + "_KEY")
sec, secOk := os.LookupEnv("AUTH_SOCIAL_" + provider + "_SECRET")
if keyOk && secOk {
return key, sec, true
} else {
log.Print(
"Binding auth endpoints without " + provider + " provider " +
"(missing key/secret, check AUTH_" + provider + "_KEY, AUTH_" + provider + "_SECRET)")
}
return "", "", false
}
if key, sec, ok := getProviderConfig("FACEBOOK"); ok {
goth.UseProviders(facebook.New(key, sec, ctrl.config.Url+"/social/facebook/callback", "email"))
}
if key, sec, ok := getProviderConfig("GPLUS"); ok {
goth.UseProviders(gplus.New(key, sec, ctrl.config.Url+"/social/gplus/callback", "email"))
}
if key, sec, ok := getProviderConfig("GITHUB"); ok {
goth.UseProviders(github.New(key, sec, ctrl.config.Url+"/social/gplus/callback", "email"))
}
if key, sec, ok := getProviderConfig("LINKEDIN"); ok {
goth.UseProviders(linkedin.New(key, sec, ctrl.config.Url+"/social/linkedin/callback", "email"))
}
// Copy provider from path (Chi URL param) to request context and return it
copyProviderToContext := func(r *http.Request) *http.Request {
return r.WithContext(context.WithValue(r.Context(), "provider", chi.URLParam(r, "provider")))
}
r.Route("/social/{provider}", func(r chi.Router) {
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
r = copyProviderToContext(r)
// Always set redir cookie, even if not requested. If param is empty, cookie is removed
ctrl.setCookie(w, r, "redir", r.URL.Query().Get("redir"))
// try to get the user without re-authenticating
if user, err := gothic.CompleteUserAuth(w, r); err != nil {
gothic.BeginAuthHandler(w, r)
} else {
// We've successfully singed-in through 3rd party auth
ctrl.handleSuccessfulAuth(w, r, user)
}
})
r.Get("/callback", func(w http.ResponseWriter, r *http.Request) {
r = copyProviderToContext(r)
if user, err := gothic.CompleteUserAuth(w, r); err != nil {
log.Printf("Failed to complete user auth: %v", err)
ctrl.handleFailedCallback(w, r, err)
} else {
ctrl.handleSuccessfulAuth(w, r, user)
}
})
r.Get("/logout", func(w http.ResponseWriter, r *http.Request) {
if err := gothic.Logout(w, r); err != nil {
log.Printf("Failed to social logout: %v", err)
}
w.Header().Set("Location", "/")
w.WriteHeader(http.StatusTemporaryRedirect)
})
})
}
func (ctrl *Social) handleFailedCallback(w http.ResponseWriter, r *http.Request, err error) {
provider := chi.URLParam(r, "provider")
if strings.Contains(err.Error(), "Error processing your OAuth request: Invalid oauth_verifier parameter") {
// Just take user through the same loop again
w.Header().Set("Location", "/social/"+provider)
w.WriteHeader(http.StatusSeeOther)
return
}
fmt.Fprintf(w, "SSO Error: %v", err.Error())
w.WriteHeader(http.StatusOK)
}
// Handles authentication via external auth providers of
// unknown an user + appending authentication on external providers
// to a current user
func (ctrl *Social) handleSuccessfulAuth(w http.ResponseWriter, r *http.Request, cred goth.User) {
log.Printf("Successful social login: %v", cred)
if u, err := ctrl.auth.With(r.Context()).Social(cred); err != nil {
resputil.JSON(w, err)
} else {
ctrl.jwtEncoder.SetCookie(w, r, u)
if c, err := r.Cookie("redir"); c != nil && err == nil {
ctrl.setCookie(w, r, "redir", "")
w.Header().Set("Location", c.Value)
w.WriteHeader(http.StatusSeeOther)
}
resputil.JSON(w, u, err)
}
}
// Extracts and authenticates JWT from context, validates claims
func (ctrl *Social) setCookie(w http.ResponseWriter, r *http.Request, name, value string) {
cookie := &http.Cookie{
Name: name,
Expires: time.Now().Add(time.Duration(ctrl.config.SessionStoreExpiry) * time.Second),
Secure: r.URL.Scheme == "https",
Domain: r.URL.Hostname(),
Path: "/social",
}
if value == "" {
cookie.Expires = time.Unix(0, 0)
} else {
cookie.Value = value
}
http.SetCookie(w, cookie)
}
+2 -2
View File
@@ -38,7 +38,7 @@ func (ctrl *User) Create(ctx context.Context, r *request.UserCreate) (interface{
Email: r.Email,
Name: r.Name,
Handle: r.Handle,
Kind: r.Kind,
Kind: types.UserKind(r.Kind),
}
return ctrl.user.With(ctx).Create(user)
@@ -50,7 +50,7 @@ func (ctrl *User) Update(ctx context.Context, r *request.UserUpdate) (interface{
Email: r.Email,
Name: r.Name,
Handle: r.Handle,
Kind: r.Kind,
Kind: types.UserKind(r.Kind),
}
return ctrl.user.With(ctx).Update(user)
+1 -1
View File
@@ -24,7 +24,7 @@ func MountRoutes(ctx context.Context, r chi.Router) {
// Only protect application routes with JWT
r.Group(func(r chi.Router) {
r.Use(jwtVerifier, jwtAuthenticator)
mountRoutes(r, flags.http, rest.MountRoutes(flags.oidc, flags.social, jwtEncoder))
mountRoutes(r, flags.http, rest.MountRoutes(jwtEncoder))
})
}
+10 -9
View File
@@ -16,6 +16,7 @@ import (
"github.com/crusttech/crust/internal/metrics"
"github.com/crusttech/crust/internal/settings"
migrate "github.com/crusttech/crust/system/db"
"github.com/crusttech/crust/system/internal/auth/external"
"github.com/crusttech/crust/system/internal/repository"
"github.com/crusttech/crust/system/service"
)
@@ -48,13 +49,6 @@ func Init(ctx context.Context) error {
// Load settings from the database,
// for now, only at start-up time.
ctx := context.Background()
// settingsRepository := internalRepository.NewSettings(repository.DB(ctx), "sys_settings")
// if ss, err := settingsRepository.Find(types.SettingsFilter{}); err != nil {
// panic(err)
// } else {
// spew.Dump(ss.KV())
// }
settingService := settings.NewService(settings.NewRepository(repository.DB(ctx), "sys_settings"))
// configure resputil options
@@ -66,8 +60,15 @@ func Init(ctx context.Context) error {
},
})
// Don't change this to init(), it needs Database
return service.Init()
// Don't change this, it needs database connection
if err := service.Init(); err != nil {
return err
}
// Setup goth/social authentication
external.Init(settingService.With(ctx))
return nil
}
func InitDatabase(ctx context.Context) error {
+11 -23
View File
@@ -8,30 +8,18 @@ import (
type (
Credentials struct {
ID uint64 `json:"credentialsID,string" db:"id"`
OwnerID uint64 `json:"ownerID,string" db:"rel_owner"`
Label string `json:"label" db:"label"`
Kind CredentialsKind `json:"kind" db:"kind"`
Credentials string `json:"-" db:"credentials"`
Meta types.JSONText `json:"-" db:"meta"`
ExpiresAt *time.Time `json:"expiresAt,omitempty" db:"expires_at"`
CreatedAt time.Time `json:"createdAt,omitempty" db:"created_at"`
DeletedAt *time.Time `json:"deletedAt,omitempty" db:"deleted_at"`
ID uint64 `json:"credentialsID,string" db:"id"`
OwnerID uint64 `json:"ownerID,string" db:"rel_owner"`
Label string `json:"label" db:"label"`
Kind string `json:"kind" db:"kind"`
Credentials string `json:"-" db:"credentials"`
Meta types.JSONText `json:"-" db:"meta"`
LastUsedAt *time.Time `json:"lastUsedAt,omitempty" db:"last_used_at"`
ExpiresAt *time.Time `json:"expiresAt,omitempty" db:"expires_at"`
CreatedAt time.Time `json:"createdAt,omitempty" db:"created_at"`
UpdatedAt *time.Time `json:"updatedAt,omitempty" db:"updated_at"`
DeletedAt *time.Time `json:"deletedAt,omitempty" db:"deleted_at"`
}
CredentialsKind string
)
const (
// Use as a password for users or as API secret for bots (and credentials-id as a key) as a value for "credentials"
CredentialsKindHash CredentialsKind = "hash"
// Identity (profile-id) stored under "credentials"
CredentialsKindFacebook CredentialsKind = "facebook"
CredentialsKindGPlus CredentialsKind = "gplus"
CredentialsKindGitHub CredentialsKind = "github"
CredentialsKindLinkedin CredentialsKind = "linkedin"
// CredentialsKindSatosa CredentialsKind = "satosa"
)
func (u *Credentials) Valid() bool {
-18
View File
@@ -6,7 +6,6 @@ import (
"time"
"github.com/pkg/errors"
"golang.org/x/crypto/bcrypt"
"github.com/crusttech/crust/internal/rules"
)
@@ -19,7 +18,6 @@ type (
Name string `json:"name" db:"name"`
Handle string `json:"handle" db:"handle"`
Kind UserKind `json:"kind" db:"kind"`
SatosaID string `json:"-" db:"satosa_id"`
Meta *UserMeta `json:"meta" db:"meta"`
@@ -27,8 +25,6 @@ type (
RelatedUserID uint64 `json:"relatedUserID,string" db:"rel_user_id"`
User *User `json:"user" db:"-"`
Password []byte `json:"-" db:"password"`
CreatedAt time.Time `json:"createdAt,omitempty" db:"created_at"`
UpdatedAt *time.Time `json:"updatedAt,omitempty" db:"updated_at"`
SuspendedAt *time.Time `json:"suspendedAt,omitempty" db:"suspended_at"`
@@ -64,20 +60,6 @@ func (u *User) Identity() uint64 {
return u.ID
}
func (u *User) ValidatePassword(password string) bool {
return bcrypt.CompareHashAndPassword(u.Password, []byte(password)) == nil
}
func (u *User) GeneratePassword(password string) error {
pwd, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
u.Password = pwd
return nil
}
// Resource returns a resource ID for this type
func (u *User) PermissionResource() rules.Resource {
return UserPermissionResource.AppendID(u.ID)
@@ -0,0 +1,412 @@
package openidConnect
import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"github.com/markbates/goth"
"golang.org/x/oauth2"
"io/ioutil"
"net/http"
"strings"
"time"
)
const (
// Standard Claims http://openid.net/specs/openid-connect-core-1_0.html#StandardClaims
// fixed, cannot be changed
subjectClaim = "sub"
expiryClaim = "exp"
audienceClaim = "aud"
issuerClaim = "iss"
PreferredUsernameClaim = "preferred_username"
EmailClaim = "email"
NameClaim = "name"
NicknameClaim = "nickname"
PictureClaim = "picture"
GivenNameClaim = "given_name"
FamilyNameClaim = "family_name"
AddressClaim = "address"
// Unused but available to set in Provider claims
MiddleNameClaim = "middle_name"
ProfileClaim = "profile"
WebsiteClaim = "website"
EmailVerifiedClaim = "email_verified"
GenderClaim = "gender"
BirthdateClaim = "birthdate"
ZoneinfoClaim = "zoneinfo"
LocaleClaim = "locale"
PhoneNumberClaim = "phone_number"
PhoneNumberVerifiedClaim = "phone_number_verified"
UpdatedAtClaim = "updated_at"
clockSkew = 10 * time.Second
)
// Provider is the implementation of `goth.Provider` for accessing OpenID Connect provider
type Provider struct {
ClientKey string
Secret string
CallbackURL string
HTTPClient *http.Client
config *oauth2.Config
openIDConfig *OpenIDConfig
providerName string
UserIdClaims []string
NameClaims []string
NickNameClaims []string
EmailClaims []string
AvatarURLClaims []string
FirstNameClaims []string
LastNameClaims []string
LocationClaims []string
SkipUserInfoRequest bool
}
type OpenIDConfig struct {
AuthEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
UserInfoEndpoint string `json:"userinfo_endpoint"`
Issuer string `json:"issuer"`
}
// New creates a new OpenID Connect provider, and sets up important connection details.
// You should always call `openidConnect.New` to get a new Provider. Never try to create
// one manually.
// New returns an implementation of an OpenID Connect Authorization Code Flow
// See http://openid.net/specs/openid-connect-core-1_0.html#CodeFlowAuth
// ID Token decryption is not (yet) supported
// UserInfo decryption is not (yet) supported
func New(clientKey, secret, callbackURL, openIDAutoDiscoveryURL string, scopes ...string) (*Provider, error) {
p := &Provider{
ClientKey: clientKey,
Secret: secret,
CallbackURL: callbackURL,
UserIdClaims: []string{subjectClaim},
NameClaims: []string{NameClaim},
NickNameClaims: []string{NicknameClaim, PreferredUsernameClaim},
EmailClaims: []string{EmailClaim},
AvatarURLClaims: []string{PictureClaim},
FirstNameClaims: []string{GivenNameClaim},
LastNameClaims: []string{FamilyNameClaim},
LocationClaims: []string{AddressClaim},
providerName: "openid-connect",
}
openIDConfig, err := getOpenIDConfig(p, openIDAutoDiscoveryURL)
if err != nil {
return nil, err
}
p.openIDConfig = openIDConfig
p.config = newConfig(p, scopes, openIDConfig)
return p, nil
}
// Name is the name used to retrieve this provider later.
func (p *Provider) Name() string {
return p.providerName
}
// SetName is to update the name of the provider (needed in case of multiple providers of 1 type)
func (p *Provider) SetName(name string) {
p.providerName = name
}
func (p *Provider) Client() *http.Client {
return goth.HTTPClientWithFallBack(p.HTTPClient)
}
// Debug is a no-op for the openidConnect package.
func (p *Provider) Debug(debug bool) {}
// BeginAuth asks the OpenID Connect provider for an authentication end-point.
func (p *Provider) BeginAuth(state string) (goth.Session, error) {
url := p.config.AuthCodeURL(state)
session := &Session{
AuthURL: url,
}
return session, nil
}
// FetchUser will use the the id_token and access requested information about the user.
func (p *Provider) FetchUser(session goth.Session) (goth.User, error) {
sess := session.(*Session)
expiresAt := sess.ExpiresAt
if sess.IDToken == "" {
return goth.User{}, fmt.Errorf("%s cannot get user information without id_token", p.providerName)
}
// decode returned id token to get expiry
claims, err := decodeJWT(sess.IDToken)
if err != nil {
return goth.User{}, fmt.Errorf("oauth2: error decoding JWT token: %v", err)
}
expiry, err := p.validateClaims(claims)
if err != nil {
return goth.User{}, fmt.Errorf("oauth2: error validating JWT token: %v", err)
}
if expiry.Before(expiresAt) {
expiresAt = expiry
}
if err := p.getUserInfo(sess.AccessToken, claims); err != nil {
return goth.User{}, err
}
user := goth.User{
AccessToken: sess.AccessToken,
Provider: p.Name(),
RefreshToken: sess.RefreshToken,
ExpiresAt: expiresAt,
RawData: claims,
}
p.userFromClaims(claims, &user)
return user, err
}
//RefreshTokenAvailable refresh token is provided by auth provider or not
func (p *Provider) RefreshTokenAvailable() bool {
return true
}
//RefreshToken get new access token based on the refresh token
func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) {
token := &oauth2.Token{RefreshToken: refreshToken}
ts := p.config.TokenSource(oauth2.NoContext, token)
newToken, err := ts.Token()
if err != nil {
return nil, err
}
return newToken, err
}
// validate according to standard, returns expiry
// http://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation
func (p *Provider) validateClaims(claims map[string]interface{}) (time.Time, error) {
audience := getClaimValue(claims, []string{audienceClaim})
if audience != p.ClientKey {
found := false
audiences := getClaimValues(claims, []string{audienceClaim})
for _, aud := range audiences {
if aud == p.ClientKey {
found = true
break
}
}
if !found {
return time.Time{}, errors.New("audience in token does not match client key")
}
}
issuer := getClaimValue(claims, []string{issuerClaim})
if issuer != p.openIDConfig.Issuer {
return time.Time{}, errors.New("issuer in token does not match issuer in OpenIDConfig discovery")
}
// expiry is required for JWT, not for UserInfoResponse
// is actually a int64, so force it in to that type
expiryClaim := int64(claims[expiryClaim].(float64))
expiry := time.Unix(expiryClaim, 0)
if expiry.Add(clockSkew).Before(time.Now()) {
return time.Time{}, errors.New("user info JWT token is expired")
}
return expiry, nil
}
func (p *Provider) userFromClaims(claims map[string]interface{}, user *goth.User) {
// required
user.UserID = getClaimValue(claims, p.UserIdClaims)
user.Name = getClaimValue(claims, p.NameClaims)
user.NickName = getClaimValue(claims, p.NickNameClaims)
user.Email = getClaimValue(claims, p.EmailClaims)
user.AvatarURL = getClaimValue(claims, p.AvatarURLClaims)
user.FirstName = getClaimValue(claims, p.FirstNameClaims)
user.LastName = getClaimValue(claims, p.LastNameClaims)
user.Location = getClaimValue(claims, p.LocationClaims)
}
func (p *Provider) getUserInfo(accessToken string, claims map[string]interface{}) error {
// skip if there is no UserInfoEndpoint or is explicitly disabled
if p.openIDConfig.UserInfoEndpoint == "" || p.SkipUserInfoRequest {
return nil
}
userInfoClaims, err := p.fetchUserInfo(p.openIDConfig.UserInfoEndpoint, accessToken)
if err != nil {
return err
}
// The sub (subject) Claim MUST always be returned in the UserInfo Response.
// http://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse
userInfoSubject := getClaimValue(userInfoClaims, []string{subjectClaim})
if userInfoSubject == "" {
return fmt.Errorf("userinfo response did not contain a 'sub' claim: %#v", userInfoClaims)
}
// The sub Claim in the UserInfo Response MUST be verified to exactly match the sub Claim in the ID Token;
// if they do not match, the UserInfo Response values MUST NOT be used.
// http://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse
subject := getClaimValue(claims, []string{subjectClaim})
if userInfoSubject != subject {
return fmt.Errorf("userinfo 'sub' claim (%s) did not match id_token 'sub' claim (%s)", userInfoSubject, subject)
}
// Merge in userinfo claims in case id_token claims contained some that userinfo did not
for k, v := range userInfoClaims {
claims[k] = v
}
return nil
}
// fetch and decode JSON from the given UserInfo URL
func (p *Provider) fetchUserInfo(url, accessToken string) (map[string]interface{}, error) {
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", accessToken))
resp, err := p.Client().Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("Non-200 response from UserInfo: %d, WWW-Authenticate=%s", resp.StatusCode, resp.Header.Get("WWW-Authenticate"))
}
// The UserInfo Claims MUST be returned as the members of a JSON object
// http://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse
data, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, err
}
return unMarshal(data)
}
func getOpenIDConfig(p *Provider, openIDAutoDiscoveryURL string) (*OpenIDConfig, error) {
res, err := p.Client().Get(openIDAutoDiscoveryURL)
if err != nil {
return nil, err
}
defer res.Body.Close()
body, err := ioutil.ReadAll(res.Body)
if err != nil {
return nil, err
}
openIDConfig := &OpenIDConfig{}
err = json.Unmarshal(body, openIDConfig)
if err != nil {
return nil, err
}
return openIDConfig, nil
}
func newConfig(provider *Provider, scopes []string, openIDConfig *OpenIDConfig) *oauth2.Config {
c := &oauth2.Config{
ClientID: provider.ClientKey,
ClientSecret: provider.Secret,
RedirectURL: provider.CallbackURL,
Endpoint: oauth2.Endpoint{
AuthURL: openIDConfig.AuthEndpoint,
TokenURL: openIDConfig.TokenEndpoint,
},
Scopes: []string{},
}
if len(scopes) > 0 {
foundOpenIDScope := false
for _, scope := range scopes {
if scope == "openid" {
foundOpenIDScope = true
}
c.Scopes = append(c.Scopes, scope)
}
if !foundOpenIDScope {
c.Scopes = append(c.Scopes, "openid")
}
} else {
c.Scopes = []string{"openid"}
}
return c
}
func getClaimValue(data map[string]interface{}, claims []string) string {
for _, claim := range claims {
if value, ok := data[claim]; ok {
if stringValue, ok := value.(string); ok && len(stringValue) > 0 {
return stringValue
}
}
}
return ""
}
func getClaimValues(data map[string]interface{}, claims []string) []string {
var result []string
for _, claim := range claims {
if value, ok := data[claim]; ok {
if stringValues, ok := value.([]interface{}); ok {
for _, stringValue := range stringValues {
if s, ok := stringValue.(string); ok && len(s) > 0 {
result = append(result, s)
}
}
}
}
}
return result
}
// decodeJWT decodes a JSON Web Token into a simple map
// http://openid.net/specs/draft-jones-json-web-token-07.html
func decodeJWT(jwt string) (map[string]interface{}, error) {
jwtParts := strings.Split(jwt, ".")
if len(jwtParts) != 3 {
return nil, errors.New("jws: invalid token received, not all parts available")
}
// Re-pad, if needed
encodedPayload := jwtParts[1]
if l := len(encodedPayload) % 4; l != 0 {
encodedPayload += strings.Repeat("=", 4-l)
}
decodedPayload, err := base64.StdEncoding.DecodeString(encodedPayload)
if err != nil {
return nil, err
}
return unMarshal(decodedPayload)
}
func unMarshal(payload []byte) (map[string]interface{}, error) {
data := make(map[string]interface{})
return data, json.NewDecoder(bytes.NewBuffer(payload)).Decode(&data)
}
+63
View File
@@ -0,0 +1,63 @@
package openidConnect
import (
"encoding/json"
"errors"
"github.com/markbates/goth"
"golang.org/x/oauth2"
"strings"
"time"
)
// Session stores data during the auth process with the OpenID Connect provider.
type Session struct {
AuthURL string
AccessToken string
RefreshToken string
ExpiresAt time.Time
IDToken string
}
// GetAuthURL will return the URL set by calling the `BeginAuth` function on the OpenID Connect provider.
func (s Session) GetAuthURL() (string, error) {
if s.AuthURL == "" {
return "", errors.New("an AuthURL has not be set")
}
return s.AuthURL, nil
}
// Authorize the session with the OpenID Connect provider and return the access token to be stored for future use.
func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) {
p := provider.(*Provider)
token, err := p.config.Exchange(oauth2.NoContext, params.Get("code"))
if err != nil {
return "", err
}
if !token.Valid() {
return "", errors.New("Invalid token received from provider")
}
s.AccessToken = token.AccessToken
s.RefreshToken = token.RefreshToken
s.ExpiresAt = token.Expiry
s.IDToken = token.Extra("id_token").(string)
return token.AccessToken, err
}
// Marshal the session into a string
func (s Session) Marshal() string {
b, _ := json.Marshal(s)
return string(b)
}
func (s Session) String() string {
return s.Marshal()
}
// UnmarshalSession will unmarshal a JSON string into a session.
func (p *Provider) UnmarshalSession(data string) (goth.Session, error) {
sess := &Session{}
err := json.NewDecoder(strings.NewReader(data)).Decode(sess)
return sess, err
}