From b1695a40428c504cdbbfef36bbddc74f9a5353f6 Mon Sep 17 00:00:00 2001 From: blancinot Date: Mon, 7 Oct 2019 18:14:11 +0200 Subject: [PATCH] feat(oauth): association userID with clientID --- api/api.go | 6 +- api/jwt.go | 2 +- api/oauth.go | 112 ++++++++++++++++ auth/etcd/etcd.go | 56 +++++++- auth/ldap-bind/ldap-bind.go | 10 +- auth/sql/sql.go | 6 + auth/stupid-auth/stupid-auth.go | 8 +- auth/users-file/users-file.go | 6 + cmd/ag-companion-api/main.go | 18 ++- pkg/companion-api/api/oauth.go | 120 +++++++++++------- pkg/companion-api/backend/client.go | 8 +- pkg/companion-api/backend/etcd/etcd.go | 36 +++++- .../backend/users-file/users-file.go | 12 ++ 13 files changed, 321 insertions(+), 79 deletions(-) create mode 100644 api/oauth.go diff --git a/api/api.go b/api/api.go index 0725b5b..4cb02ac 100644 --- a/api/api.go +++ b/api/api.go @@ -4,8 +4,8 @@ import ( "errors" "time" - "github.com/dgrijalva/jwt-go" - "github.com/emicklei/go-restful" + jwt "github.com/dgrijalva/jwt-go" + restful "github.com/emicklei/go-restful" ) var ( @@ -16,6 +16,7 @@ var ( // Authenticator is the interface for authn backends type Authenticator interface { Authenticate(user, password string, expiresAt time.Time) (claims jwt.Claims, err error) + FindUser(clientID, provider string, expiresAt time.Time) (user string, claims jwt.Claims, err error) } // API registering with restful @@ -36,5 +37,6 @@ func (api *API) Register() *restful.WebService { api.registerKeystone(ws) api.registerK8sAuthenticator(ws) api.registerCertificate(ws) + api.registerOauth(ws) return ws } diff --git a/api/jwt.go b/api/jwt.go index 14fcea8..78e3051 100644 --- a/api/jwt.go +++ b/api/jwt.go @@ -3,7 +3,7 @@ package api import ( "time" - "github.com/dgrijalva/jwt-go" + jwt "github.com/dgrijalva/jwt-go" "github.com/mcluseau/autentigo/auth" ) diff --git a/api/oauth.go b/api/oauth.go new file mode 100644 index 0000000..1fc5cba --- /dev/null +++ b/api/oauth.go @@ -0,0 +1,112 @@ +package api + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "os" + "strings" + "time" + + restful "github.com/emicklei/go-restful" +) + +const bearerPrefix = "Bearer " + +func (api *API) registerOauth(ws *restful.WebService) { + ws. + Route(ws.GET("/oauth/{provider}"). + To(api.oauthAuthenticate). + Doc("Authenticate using oauth token"). + Param(restful.HeaderParameter("Authorization", "Oauth authorization header")). + Produces("application/json"). + Writes(AuthResponse{})) +} + +func (api *API) oauthAuthenticate(request *restful.Request, response *restful.Response) { + defer func() { + if err := recover(); err != nil { + // unhandled error + WriteError(err.(error), response) + } + }() + + authHeader := request.HeaderParameter("Authorization") + if !strings.HasPrefix(authHeader, bearerPrefix) { + response.WriteErrorString(http.StatusUnauthorized, "missing bearer prefix") + return + } + + accessToken := authHeader[len(bearerPrefix):] + provider := request.PathParameter("provider") + + baseURL, err := oauthClientIdentityURL(provider) + if err != nil { + response.WriteError(http.StatusBadRequest, err) + return + } + + identityResponse, err := http.Get(baseURL + "?access_token=" + accessToken) + if err != nil { + response.WriteError(http.StatusUnauthorized, fmt.Errorf("failed getting client identity by oauth: %s", err.Error())) + return + } + + defer identityResponse.Body.Close() + contents, err := ioutil.ReadAll(identityResponse.Body) + if err != nil { + response.WriteError(http.StatusUnprocessableEntity, fmt.Errorf("failed reading response body: %s", err.Error())) + return + } + + var clientIdentity map[string]interface{} + if err := json.Unmarshal(contents, &clientIdentity); err != nil { + response.WriteError(http.StatusUnprocessableEntity, fmt.Errorf("failed unmarshalling contents: %s", err.Error())) + return + } + + id := safeStringValue(clientIdentity, "id") + if len(id) == 0 { + id = safeStringValue(clientIdentity, "sub") // different between some oauth providers + if len(id) == 0 { + response.WriteErrorString(http.StatusUnprocessableEntity, "client identity given by oauth is unprocessable") + return + } + } + + exp := time.Now().Add(api.TokenDuration) + user, claims, err := api.Authenticator.FindUser(id, provider, exp) + if err != nil { + response.WriteError(http.StatusUnprocessableEntity, fmt.Errorf("associated user not found: %s", err.Error())) + return + } + _, tokenString, err := api.createToken(user, claims) + if err != nil { + panic(err) + } + + _, err = api.checkToken(tokenString) + if err != nil { + panic(err) + } + + response.WriteEntity(&AuthResponse{tokenString, claims}) +} + +func safeStringValue(m map[string]interface{}, field string) string { + v, ok := m[field] + if !ok { + return "" + } + return v.(string) +} + +func oauthClientIdentityURL(provider string) (value string, err error) { + urlEnv := strings.ToUpper(provider) + "_USERIDENTITYURL" + value = os.Getenv(urlEnv) + if len(value) == 0 { + err = fmt.Errorf("client identity url given by provider %s is missing, please verify autentigo configuration [%s]", provider, urlEnv) + } + return +} diff --git a/auth/etcd/etcd.go b/auth/etcd/etcd.go index 1e9e3c9..3c4c01e 100644 --- a/auth/etcd/etcd.go +++ b/auth/etcd/etcd.go @@ -5,6 +5,7 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "errors" "log" "os" "path" @@ -17,6 +18,10 @@ import ( "github.com/mcluseau/autentigo/auth" ) +const ( + oauthprefix = "/oauth" +) + // New Authenticator with etcd backend func New(prefix string, endpoints []string) api.Authenticator { client, err := clientv3.New(clientv3.Config{ @@ -64,7 +69,29 @@ func (a *etcdAuth) Authenticate(user, password string, expiresAt time.Time) (cla ctx, cancel := context.WithTimeout(context.Background(), a.timeout) defer cancel() - resp, err := a.client.Get(ctx, path.Join(a.prefix, user)) + u := &User{} + if u, err = a.getUser(ctx, user); err != nil { + return + } + + if u.PasswordHash != passwordHash { + err = api.ErrInvalidAuthentication + return + } + + claims = auth.Claims{ + StandardClaims: jwt.StandardClaims{ + IssuedAt: time.Now().Unix(), + ExpiresAt: expiresAt.Unix(), + Subject: user, + }, + ExtraClaims: u.ExtraClaims, + } + return +} + +func (a *etcdAuth) getUser(ctx context.Context, userID string) (user *User, err error) { + resp, err := a.client.Get(ctx, path.Join(a.prefix, userID)) if err != nil { return } @@ -74,13 +101,28 @@ func (a *etcdAuth) Authenticate(user, password string, expiresAt time.Time) (cla return } - u := User{} - if err = json.Unmarshal(resp.Kvs[0].Value, &u); err != nil { + err = json.Unmarshal(resp.Kvs[0].Value, user) + return +} + +func (a *etcdAuth) FindUser(clientID, provider string, expiresAt time.Time) (userID string, claims jwt.Claims, err error) { + + ctx, cancel := context.WithTimeout(context.Background(), a.timeout) + defer cancel() + + var resp *clientv3.GetResponse + if resp, err = a.client.Get(ctx, path.Join(oauthprefix, a.prefix, provider, clientID)); err != nil { + return + } + + if len(resp.Kvs) == 0 { + err = errors.New("unknown user") return } - if u.PasswordHash != passwordHash { - err = api.ErrInvalidAuthentication + userID = string(resp.Kvs[0].Value) + user := &User{} + if user, err = a.getUser(ctx, userID); err != nil { return } @@ -88,9 +130,9 @@ func (a *etcdAuth) Authenticate(user, password string, expiresAt time.Time) (cla StandardClaims: jwt.StandardClaims{ IssuedAt: time.Now().Unix(), ExpiresAt: expiresAt.Unix(), - Subject: user, + Subject: userID, }, - ExtraClaims: u.ExtraClaims, + ExtraClaims: user.ExtraClaims, } return } diff --git a/auth/ldap-bind/ldap-bind.go b/auth/ldap-bind/ldap-bind.go index 12d750d..8e148dd 100644 --- a/auth/ldap-bind/ldap-bind.go +++ b/auth/ldap-bind/ldap-bind.go @@ -2,14 +2,15 @@ package ldapbind import ( "crypto/tls" + "errors" "fmt" "log" "net/url" "time" - "github.com/dgrijalva/jwt-go" + jwt "github.com/dgrijalva/jwt-go" "github.com/mcluseau/autentigo/api" - "gopkg.in/ldap.v2" + ldap "gopkg.in/ldap.v2" ) // New Authenticator with ldap backend @@ -64,3 +65,8 @@ func (a auth) Authenticate(user, password string, expiresAt time.Time) (jwt.Clai Subject: user, }, nil } + +func (a auth) FindUser(clientID, provider string, expiresAt time.Time) (userID string, claims jwt.Claims, err error) { + err = errors.New("inconsistent with Ldap backend") + return +} diff --git a/auth/sql/sql.go b/auth/sql/sql.go index fe09f38..7a2f25b 100644 --- a/auth/sql/sql.go +++ b/auth/sql/sql.go @@ -4,6 +4,7 @@ import ( "crypto/sha256" "database/sql" "encoding/hex" + "errors" "fmt" "strings" "time" @@ -74,3 +75,8 @@ func (sa sqlAuth) Authenticate(user, password string, expiresAt time.Time) (clai return } + +func (sa sqlAuth) FindUser(clientID, provider string, expiresAt time.Time) (userID string, claims jwt.Claims, err error) { + err = errors.New("Not implemented yet") + return +} diff --git a/auth/stupid-auth/stupid-auth.go b/auth/stupid-auth/stupid-auth.go index 6406fc9..c2fd605 100644 --- a/auth/stupid-auth/stupid-auth.go +++ b/auth/stupid-auth/stupid-auth.go @@ -1,9 +1,10 @@ package stupidauth import ( + "errors" "time" - "github.com/dgrijalva/jwt-go" + jwt "github.com/dgrijalva/jwt-go" "github.com/mcluseau/autentigo/api" ) @@ -23,3 +24,8 @@ func (sa stupidAuth) Authenticate(user, password string, expiresAt time.Time) (j Subject: user, }, nil } + +func (sa stupidAuth) FindUser(clientID, provider string, expiresAt time.Time) (userID string, claims jwt.Claims, err error) { + err = errors.New("inconsistent with stupid auth") + return +} diff --git a/auth/users-file/users-file.go b/auth/users-file/users-file.go index fecc085..f73be96 100644 --- a/auth/users-file/users-file.go +++ b/auth/users-file/users-file.go @@ -4,6 +4,7 @@ import ( "crypto/sha256" "encoding/csv" "encoding/hex" + "errors" "io" "os" "strings" @@ -96,3 +97,8 @@ func (a usersFileAuth) Authenticate(user, password string, expiresAt time.Time) return nil, api.ErrInvalidAuthentication } + +func (a usersFileAuth) FindUser(clientID, provider string, expiresAt time.Time) (userID string, claims jwt.Claims, err error) { + err = errors.New("Not implemented yet") + return +} diff --git a/cmd/ag-companion-api/main.go b/cmd/ag-companion-api/main.go index 621bfc1..fc96696 100644 --- a/cmd/ag-companion-api/main.go +++ b/cmd/ag-companion-api/main.go @@ -2,7 +2,6 @@ package main import ( "flag" - "io/ioutil" "log" "net" "net/http" @@ -17,8 +16,7 @@ import ( companionapi "github.com/mcluseau/autentigo/pkg/companion-api/api" "github.com/mcluseau/autentigo/pkg/companion-api/backend" "github.com/mcluseau/autentigo/pkg/companion-api/backend/etcd" - "github.com/mcluseau/autentigo/pkg/companion-api/backend/users-file" - "github.com/mcluseau/autentigo/pkg/rbac" + usersfile "github.com/mcluseau/autentigo/pkg/companion-api/backend/users-file" ) var ( @@ -36,14 +34,14 @@ func main() { var err error - if rbac.Default, err = rbac.FromFile(*rbacFile); err != nil { - log.Fatal("failed to load RBAC rules: ", err) - } - - if rbac.DefaultValidationCertificate, err = ioutil.ReadFile(*validationCrtPath); err != nil { - log.Fatal("failed to read validation certificate: ", err) - } + /* if rbac.Default, err = rbac.FromFile(*rbacFile); err != nil { + log.Fatal("failed to load RBAC rules: ", err) + } + if rbac.DefaultValidationCertificate, err = ioutil.ReadFile(*validationCrtPath); err != nil { + log.Fatal("failed to read validation certificate: ", err) + } + */ cAPI := &companionapi.CompanionAPI{ Client: getBackEndClient(), AdminToken: *adminToken, diff --git a/pkg/companion-api/api/oauth.go b/pkg/companion-api/api/oauth.go index 7591463..85b8d69 100644 --- a/pkg/companion-api/api/oauth.go +++ b/pkg/companion-api/api/oauth.go @@ -10,12 +10,18 @@ import ( "strings" restful "github.com/emicklei/go-restful" + uuid "github.com/nu7hatch/gouuid" "golang.org/x/oauth2" "github.com/mcluseau/autentigo/auth" "github.com/mcluseau/autentigo/pkg/companion-api/backend" ) +type State struct { + State string + UserID string +} + func oauthConfig(provider string) *oauth2.Config { upperCaseProvider := strings.ToUpper(provider) @@ -31,9 +37,9 @@ func oauthConfig(provider string) *oauth2.Config { } } -func oauthUserInfosURL(provider string) string { +func oauthClientIdentityURL(provider string) string { upperCaseProvider := strings.ToUpper(provider) - return requireEnv(upperCaseProvider+"_USERINFOSURL", "url of the client user informations provider") + return requireEnv(upperCaseProvider+"_USERIDENTITYURL", "client identity url given by provider "+provider) } func oauthState() (state string) { @@ -53,7 +59,7 @@ func (cApi *CompanionAPI) oauthWS() (ws *restful.WebService) { ws.Path("/oauth") ws. - Route(ws.GET("/{provider}"). + Route(ws.GET("/{provider}/{user-id}"). To(cApi.register). Param(ws.PathParameter("provider", "oauth client").DataType("string")). Doc("Create or update user with informations given by oauth")) @@ -69,23 +75,39 @@ func (cApi *CompanionAPI) oauthWS() (ws *restful.WebService) { func (cApi *CompanionAPI) register(request *restful.Request, response *restful.Response) { provider := request.PathParameter("provider") - url := oauthConfig(provider).AuthCodeURL(oauthState()) - http.Redirect(response.ResponseWriter, request.Request, url, http.StatusTemporaryRedirect) -} + userID := request.PathParameter("user-id") + + if len(userID) == 0 { + id, err := uuid.NewV4() + if err != nil { + response.WriteError(http.StatusUnprocessableEntity, err) + return + } + userID = id.String() + } -type OAuthUserInfos struct { - ID string - Name string - Email string + state, err := json.Marshal(State{oauthState(), userID}) + if err != nil { + panic(err) + } + + url := oauthConfig(provider).AuthCodeURL(string(state)) + http.Redirect(response.ResponseWriter, request.Request, url, http.StatusTemporaryRedirect) } func (cApi *CompanionAPI) callback(request *restful.Request, response *restful.Response) { provider := request.PathParameter("provider") - state := request.Request.FormValue("state") code := request.Request.FormValue("code") - if state != oauthState() { + state := &State{} + if err := json.Unmarshal([]byte(request.Request.FormValue("state")), state); err != nil { + response.WriteErrorString(http.StatusUnprocessableEntity, "invalid oauth state form") + return + } + + if state.State != oauthState() { response.WriteErrorString(http.StatusUnauthorized, "invalid oauth state") + return } token, err := oauthConfig(provider).Exchange(oauth2.NoContext, code) @@ -98,74 +120,78 @@ func (cApi *CompanionAPI) callback(request *restful.Request, response *restful.R return } - infoResp, err := http.Get(oauthUserInfosURL(provider) + "?fields=name,email&access_token=" + token.AccessToken) + identityResponse, err := http.Get(oauthClientIdentityURL(provider) + "?access_token=" + token.AccessToken) if err != nil { - response.WriteError(http.StatusUnauthorized, fmt.Errorf("failed getting user info: %s", err.Error())) + response.WriteError(http.StatusUnauthorized, fmt.Errorf("failed getting client identity: %s", err.Error())) return } - defer infoResp.Body.Close() - contents, err := ioutil.ReadAll(infoResp.Body) + defer identityResponse.Body.Close() + contents, err := ioutil.ReadAll(identityResponse.Body) if err != nil { response.WriteError(http.StatusUnprocessableEntity, fmt.Errorf("failed reading response body: %s", err.Error())) return } - userInfos := OAuthUserInfos{} - if err := json.Unmarshal(contents, &userInfos); err != nil { + var clientIdentity map[string]interface{} + if err := json.Unmarshal(contents, &clientIdentity); err != nil { response.WriteError(http.StatusUnprocessableEntity, fmt.Errorf("failed unmarshalling contents: %s", err.Error())) return } - if len(userInfos.ID) == 0 { - response.WriteErrorString(http.StatusUnprocessableEntity, "user infos given by oauth are unprocessable") - return + id := safeStringValue(clientIdentity, "id") + name := safeStringValue(clientIdentity, "name") + email := safeStringValue(clientIdentity, "email") + + if len(id) == 0 { + id = safeStringValue(clientIdentity, "sub") // different between some oauth providers + if len(id) == 0 { + response.WriteErrorString(http.StatusUnprocessableEntity, "client identity given by oauth is unprocessable") + return + } } b := &backend.UserData{ - OauthTokens: []backend.OauthToken{backend.OauthToken{ - Provider: provider, - Token: token.AccessToken, - }}, ExtraClaims: auth.ExtraClaims{ - DisplayName: userInfos.Name, - Email: userInfos.Email, + DisplayName: name, + Email: email, EmailVerified: true, }, } - err = cApi.Client.CreateUser(userInfos.ID, b) + err = cApi.Client.CreateUser(state.UserID, b) if err == ErrUserAlreadyExist { - err = cApi.Client.UpdateUser(userInfos.ID, func(user *backend.UserData) (_ error) { - found := false - for _, uToken := range user.OauthTokens { - if uToken.Provider == provider { - uToken.Token = token.AccessToken - found = true - break - } - } - if !found { - user.OauthTokens = append(user.OauthTokens, backend.OauthToken{ - Provider: provider, - Token: token.AccessToken, - }) - } - user.ExtraClaims.DisplayName = userInfos.Name - if len(userInfos.Email) != 0 && userInfos.Email != user.ExtraClaims.Email { - user.ExtraClaims.Email = userInfos.Email + err = cApi.Client.UpdateUser(state.UserID, func(user *backend.UserData) (_ error) { + user.ExtraClaims.DisplayName = name + if len(email) != 0 && email != user.ExtraClaims.Email { + user.ExtraClaims.Email = email user.ExtraClaims.EmailVerified = true } return }) } if err != nil { - response.WriteError(http.StatusInternalServerError, fmt.Errorf("user infos cannot be upgraded in backend: %s", err.Error())) + response.WriteError(http.StatusInternalServerError, fmt.Errorf("client identity cannot be upgraded in backend: %s", err.Error())) + return } + + if err = cApi.Client.PutUserID(provider, id, state.UserID); err != nil { + response.WriteError(http.StatusInternalServerError, fmt.Errorf("oauth information cannot be upgraded in backend: %s", err.Error())) + return + } + response.AddHeader("Authorization", "Bearer "+token.AccessToken) response.WriteHeader(http.StatusOK) } +func safeStringValue(m map[string]interface{}, field string) string { + v, ok := m[field] + if !ok { + return "" + } + return v.(string) +} + func requireEnv(name, description string) string { v := os.Getenv(name) if v == "" { diff --git a/pkg/companion-api/backend/client.go b/pkg/companion-api/backend/client.go index 25b90c7..2b1fd93 100644 --- a/pkg/companion-api/backend/client.go +++ b/pkg/companion-api/backend/client.go @@ -7,18 +7,14 @@ import ( // UserData is a simple user struct with paswordhash and claims type UserData struct { PasswordHash string `json:"password"` - OauthTokens []OauthToken `json:"oauth_tokens"` ExtraClaims auth.ExtraClaims `json:"claims"` } -type OauthToken struct { - Provider string `json:"provider"` - Token string `json:"token"` -} - // Client is the interface for all backends clients type Client interface { CreateUser(id string, user *UserData) error UpdateUser(id string, update func(user *UserData) error) error DeleteUser(id string) error + GetUserID(provider, clientID string) (string, error) + PutUserID(provider, clientID, userID string) error } diff --git a/pkg/companion-api/backend/etcd/etcd.go b/pkg/companion-api/backend/etcd/etcd.go index c8205a8..7f5c94a 100644 --- a/pkg/companion-api/backend/etcd/etcd.go +++ b/pkg/companion-api/backend/etcd/etcd.go @@ -13,6 +13,10 @@ import ( "github.com/mcluseau/autentigo/pkg/companion-api/backend" ) +const ( + oauthprefix = "/oauth" +) + type etcdClient struct { prefix string client *clientv3.Client @@ -86,13 +90,39 @@ func (e *etcdClient) DeleteUser(id string) (err error) { return } -func (e *etcdClient) getUser(id string) (user *backend.UserData, err error) { +func (e *etcdClient) PutUserID(provider, clientID, userID string) (err error) { + ctx, cancel := context.WithTimeout(context.Background(), e.timeout) + defer cancel() + + _, err = e.client.Put(ctx, path.Join(oauthprefix, e.prefix, provider, clientID), userID) + return +} + +func (e *etcdClient) GetUserID(provider, clientID string) (userID string, err error) { ctx, cancel := context.WithTimeout(context.Background(), e.timeout) defer cancel() - resp, err := e.client.Get(ctx, path.Join(e.prefix, id)) - if err != nil { + var resp *clientv3.GetResponse + if resp, err = e.client.Get(ctx, path.Join(oauthprefix, e.prefix, provider, clientID)); err != nil { + return + } + + if len(resp.Kvs) == 0 { + err = api.ErrMissingUser + return + } + + userID = string(resp.Kvs[0].Value) + return +} + +func (e *etcdClient) getUser(id string) (user *backend.UserData, err error) { + ctx, cancel := context.WithTimeout(context.Background(), e.timeout) + defer cancel() + + var resp *clientv3.GetResponse + if resp, err = e.client.Get(ctx, path.Join(e.prefix, id)); err != nil { return } diff --git a/pkg/companion-api/backend/users-file/users-file.go b/pkg/companion-api/backend/users-file/users-file.go index d16f982..c8ac7db 100644 --- a/pkg/companion-api/backend/users-file/users-file.go +++ b/pkg/companion-api/backend/users-file/users-file.go @@ -162,6 +162,18 @@ func (fc *fileClient) putUser(id, passwordHash string, claims auth.ExtraClaims) return nil } +func (fc *fileClient) GetUserID(provider, clientID string) (userID string, err error) { + + //TODO: need to be implemented + return +} + +func (fc *fileClient) PutUserID(provider, clientID, userID string) (err error) { + + //TODO: need to be implemented + return +} + func (fc *fileClient) getUser(id string) (*backend.UserData, error) { reader, err := newUsersFileReader(fc.filePath)