diff --git a/db/refresh.go b/db/refresh.go index 552cc1e6..86495748 100644 --- a/db/refresh.go +++ b/db/refresh.go @@ -14,6 +14,7 @@ import ( "github.com/coreos/dex/pkg/log" "github.com/coreos/dex/refresh" "github.com/coreos/dex/repo" + "github.com/coreos/go-oidc/oidc" ) const ( @@ -179,6 +180,35 @@ func (r *refreshTokenRepo) Revoke(userID, token string) error { return tx.Commit() } +func (r *refreshTokenRepo) RevokeTokensForClient(userID, clientID string) error { + q := fmt.Sprintf("DELETE FROM %s WHERE user_id = $1 AND client_id = $2", r.quote(refreshTokenTableName)) + _, err := r.executor(nil).Exec(q, userID, clientID) + return err +} + +func (r *refreshTokenRepo) ClientsWithRefreshTokens(userID string) ([]oidc.ClientIdentity, error) { + q := `SELECT c.* FROM %s as c + INNER JOIN %s as r ON c.id = r.client_id WHERE r.user_id = $1;` + q = fmt.Sprintf(q, r.quote(clientIdentityTableName), r.quote(refreshTokenTableName)) + + var clients []clientIdentityModel + if _, err := r.executor(nil).Select(&clients, q, userID); err != nil { + return nil, err + } + + c := make([]oidc.ClientIdentity, len(clients)) + for i, client := range clients { + ident, err := client.ClientIdentity() + if err != nil { + return nil, err + } + c[i] = *ident + // Do not share the secret. + c[i].Credentials.Secret = "" + } + return c, nil +} + func (r *refreshTokenRepo) get(tx repo.Transaction, tokenID int64) (*refreshTokenModel, error) { ex := r.executor(tx) result, err := ex.Get(refreshTokenModel{}, tokenID) diff --git a/functional/repo/client_repo_test.go b/functional/repo/client_repo_test.go index 6c580d68..fff50ca1 100644 --- a/functional/repo/client_repo_test.go +++ b/functional/repo/client_repo_test.go @@ -24,7 +24,8 @@ var ( RedirectURIs: []url.URL{ url.URL{ Scheme: "https", - Host: "client1.example.com/callback", + Host: "client1.example.com", + Path: "/callback", }, }, }, @@ -38,7 +39,8 @@ var ( RedirectURIs: []url.URL{ url.URL{ Scheme: "https", - Host: "client2.example.com/callback", + Host: "client2.example.com", + Path: "/callback", }, }, }, diff --git a/functional/repo/refresh_repo_test.go b/functional/repo/refresh_repo_test.go new file mode 100644 index 00000000..197f59ad --- /dev/null +++ b/functional/repo/refresh_repo_test.go @@ -0,0 +1,93 @@ +package repo + +import ( + "encoding/base64" + "net/url" + "os" + "testing" + "time" + + "github.com/coreos/go-oidc/oidc" + "github.com/go-gorp/gorp" + "github.com/kylelemons/godebug/pretty" + + "github.com/coreos/dex/db" + "github.com/coreos/dex/refresh" + "github.com/coreos/dex/user" +) + +func newRefreshRepo(t *testing.T, users []user.UserWithRemoteIdentities, clients []oidc.ClientIdentity) refresh.RefreshTokenRepo { + var dbMap *gorp.DbMap + if dsn := os.Getenv("DEX_TEST_DSN"); dsn == "" { + dbMap = db.NewMemDB() + } else { + dbMap = connect(t) + } + if _, err := db.NewUserRepoFromUsers(dbMap, users); err != nil { + t.Fatalf("Unable to add users: %v", err) + } + if _, err := db.NewClientIdentityRepoFromClients(dbMap, clients); err != nil { + t.Fatalf("Unable to add clients: %v", err) + } + return db.NewRefreshTokenRepo(dbMap) +} + +func TestRefreshTokenRepo(t *testing.T) { + clientID := "client1" + userID := "user1" + clients := []oidc.ClientIdentity{ + { + Credentials: oidc.ClientCredentials{ + ID: clientID, + Secret: base64.URLEncoding.EncodeToString([]byte("secret-2")), + }, + Metadata: oidc.ClientMetadata{ + RedirectURIs: []url.URL{ + url.URL{Scheme: "https", Host: "client1.example.com", Path: "/callback"}, + }, + }, + }, + } + users := []user.UserWithRemoteIdentities{ + { + User: user.User{ + ID: userID, + Email: "Email-1@example.com", + CreatedAt: time.Now().Truncate(time.Second), + }, + RemoteIdentities: []user.RemoteIdentity{ + { + ConnectorID: "IDPC-1", + ID: "RID-1", + }, + }, + }, + } + + repo := newRefreshRepo(t, users, clients) + tok, err := repo.Create(userID, clientID) + if err != nil { + t.Fatalf("failed to create refresh token: %v", err) + } + if tokUserID, err := repo.Verify(clientID, tok); err != nil { + t.Errorf("Could not verify token: %v", err) + } else if tokUserID != userID { + t.Errorf("Verified token returned wrong user id, want=%s, got=%s", userID, tokUserID) + } + + if userClients, err := repo.ClientsWithRefreshTokens(userID); err != nil { + t.Errorf("Failed to get the list of clients the user was logged into: %v", err) + } else { + if diff := pretty.Compare(userClients, clients); diff == "" { + t.Errorf("Clients user logged into: want did not equal got %s", diff) + } + } + + if err := repo.RevokeTokensForClient(userID, clientID); err != nil { + t.Errorf("Failed to revoke refresh token: %v", err) + } + + if _, err := repo.Verify(clientID, tok); err == nil { + t.Errorf("Token which should have been revoked was verified") + } +} diff --git a/integration/user_api_test.go b/integration/user_api_test.go index f2f4758f..14a2f161 100644 --- a/integration/user_api_test.go +++ b/integration/user_api_test.go @@ -6,6 +6,7 @@ import ( "net/http" "net/http/httptest" "net/url" + "sort" "strings" "testing" "time" @@ -99,10 +100,9 @@ var ( func makeUserAPITestFixtures() *userAPITestFixtures { f := &userAPITestFixtures{} - _, _, _, um := makeUserObjects(userUsers, userPasswords) - + dbMap, _, _, um := makeUserObjects(userUsers, userPasswords) cir := func() client.ClientIdentityRepo { - repo, err := db.NewClientIdentityRepoFromClients(db.NewMemDB(), []oidc.ClientIdentity{ + repo, err := db.NewClientIdentityRepoFromClients(dbMap, []oidc.ClientIdentity{ oidc.ClientIdentity{ Credentials: oidc.ClientCredentials{ ID: testClientID, @@ -144,8 +144,16 @@ func makeUserAPITestFixtures() *userAPITestFixtures { return oidc.NewJWTVerifier(testIssuerURL.String(), clientID, noop, keysFunc) } + refreshRepo := db.NewRefreshTokenRepo(dbMap) + for _, user := range userUsers { + if _, err := refreshRepo.Create(user.User.ID, testClientID); err != nil { + panic("Failed to create refresh token: " + err.Error()) + } + } + f.emailer = &testEmailer{} - api := api.NewUsersAPI(um, cir, f.emailer, "local") + um.Clock = clock + api := api.NewUsersAPI(dbMap, um, f.emailer, "local") usrSrv := server.NewUserMgmtServer(api, jwtvFactory, um, cir) f.hSrv = httptest.NewServer(usrSrv.HTTPHandler()) @@ -584,6 +592,48 @@ func TestDisableUser(t *testing.T) { } } +func TestRefreshTokenEndpoints(t *testing.T) { + + tests := []struct { + userID string + clients []string + }{ + {"ID-1", []string{testClientID}}, + {"ID-2", []string{testClientID}}, + } + + for i, tt := range tests { + f := makeUserAPITestFixtures() + list, err := f.client.RefreshClient.List(tt.userID).Do() + if err != nil { + t.Errorf("case %d: list clients: %v", i, err) + continue + } + var ids []string + for _, client := range list.Clients { + ids = append(ids, client.ClientID) + } + sort.Strings(ids) + sort.Strings(tt.clients) + if diff := pretty.Compare(tt.clients, ids); diff != "" { + t.Errorf("case %d: expected client ids did not match actual: %s", i, diff) + } + for _, clientID := range ids { + if err := f.client.Clients.Revoke(tt.userID, clientID).Do(); err != nil { + t.Errorf("case %d: failed to revoke client: %v", i, err) + } + } + list, err = f.client.RefreshClient.List(tt.userID).Do() + if err != nil { + t.Errorf("case %d: list clients after revocation: %v", i, err) + continue + } + if n := len(list.Clients); n != 0 { + t.Errorf("case %d: expected no refresh tokens after revocation, got %d", i, n) + } + } +} + func TestResendEmailInvitation(t *testing.T) { tests := []struct { req schema.ResendEmailInvitationRequest diff --git a/refresh/repo.go b/refresh/repo.go index 0c65c0e6..607169fe 100644 --- a/refresh/repo.go +++ b/refresh/repo.go @@ -3,6 +3,8 @@ package refresh import ( "crypto/rand" "errors" + + "github.com/coreos/go-oidc/oidc" ) const ( @@ -47,4 +49,10 @@ type RefreshTokenRepo interface { // Revoke deletes the refresh token if the token belongs to the given userID. Revoke(userID, token string) error + + // RevokeTokensForClient revokes all tokens issued for the userID for the provided client. + RevokeTokensForClient(userID, clientID string) error + + // ClientsWithRefreshTokens returns a list of all clients the user has an outstanding client with. + ClientsWithRefreshTokens(userID string) ([]oidc.ClientIdentity, error) } diff --git a/schema/generator b/schema/generator index 54effc73..56c5e5aa 100755 --- a/schema/generator +++ b/schema/generator @@ -33,8 +33,15 @@ fi $GENDOC --f $IN --o $DOC -# See schema/generator_import.go for instructions on updating the dependency -PKG="google.golang.org/api/google-api-go-generator" +# Though google-api-go-generator is a main, dex vendors the app using the same +# tool it uses to vendor third party packages. Hence, it can be found in the +# "vendor" directory. +# +# This vendoring is currently done with godep, but may change if/when we move to +# another tool. +# +# See schema/generator_import.go for instructions on updating the dependency. +PKG="github.com/coreos/dex/vendor/google.golang.org/api/google-api-go-generator" # First, write the discovery document into a go file so it can be served statically by the API cat << EOF > "${OUT}" @@ -53,7 +60,7 @@ echo -n '`' >> "${OUT}" # Now build google-api-go-generator - we vendor so this is consistently reproducible GEN_PATH="bin/google-api-go-generator" if [ ! -f ${GEN_PATH} ]; then - GOPATH="${PWD}/Godeps/_workspace" go build -o ${GEN_PATH} ${PKG} + go build -o ${GEN_PATH} ${PKG} fi # Build the bindings diff --git a/schema/workerschema/README.md b/schema/workerschema/README.md index f8f2bf8c..203b3154 100644 --- a/schema/workerschema/README.md +++ b/schema/workerschema/README.md @@ -59,6 +59,31 @@ __Version:__ v1 } ``` +### RefreshClient + +A client with associated public metadata. + +``` +{ + clientID: string, + clientName: string, + clientURI: string, + logoURI: string +} +``` + +### RefreshClientList + + + +``` +{ + clients: [ + RefreshClient + ] +} +``` + ### ResendEmailInvitationRequest @@ -166,6 +191,58 @@ __Version:__ v1 ## Paths +### GET /account/{userid}/refresh + +> __Summary__ + +> List RefreshClient + +> __Description__ + +> List all clients that hold refresh tokens for the authenticated user. + + +> __Parameters__ + +> |Name|Located in|Description|Required|Type| +|:-----|:-----|:-----|:-----|:-----| +| userid | path | | Yes | string | + + +> __Responses__ + +> |Code|Description|Type| +|:-----|:-----|:-----| +| 200 | | [RefreshClientList](#refreshclientlist) | +| default | Unexpected error | | + + +### DELETE /account/{userid}/refresh/{clientid} + +> __Summary__ + +> Revoke Clients + +> __Description__ + +> Revoke all refresh tokens issues to the client for the authenticated user. + + +> __Parameters__ + +> |Name|Located in|Description|Required|Type| +|:-----|:-----|:-----|:-----|:-----| +| clientid | path | | Yes | string | +| userid | path | | Yes | string | + + +> __Responses__ + +> |Code|Description|Type| +|:-----|:-----|:-----| +| default | Unexpected error | | + + ### GET /clients > __Summary__ diff --git a/schema/workerschema/v1-gen.go b/schema/workerschema/v1-gen.go index 2d410541..9b71f95f 100644 --- a/schema/workerschema/v1-gen.go +++ b/schema/workerschema/v1-gen.go @@ -14,12 +14,13 @@ import ( "encoding/json" "errors" "fmt" - "google.golang.org/api/googleapi" "io" "net/http" "net/url" "strconv" "strings" + + "google.golang.org/api/googleapi" ) // Always reference these packages, just in case the auto-generated code @@ -45,6 +46,7 @@ func New(client *http.Client) (*Service, error) { } s := &Service{client: client, BasePath: basePath} s.Clients = NewClientsService(s) + s.RefreshClient = NewRefreshClientService(s) s.Users = NewUsersService(s) return s, nil } @@ -55,6 +57,8 @@ type Service struct { Clients *ClientsService + RefreshClient *RefreshClientService + Users *UsersService } @@ -67,6 +71,15 @@ type ClientsService struct { s *Service } +func NewRefreshClientService(s *Service) *RefreshClientService { + rs := &RefreshClientService{s: s} + return rs +} + +type RefreshClientService struct { + s *Service +} + func NewUsersService(s *Service) *UsersService { rs := &UsersService{s: s} return rs @@ -102,6 +115,20 @@ type Error struct { Error_description string `json:"error_description,omitempty"` } +type RefreshClient struct { + ClientID string `json:"clientID,omitempty"` + + ClientName string `json:"clientName,omitempty"` + + ClientURI string `json:"clientURI,omitempty"` + + LogoURI string `json:"logoURI,omitempty"` +} + +type RefreshClientList struct { + Clients []*RefreshClient `json:"clients,omitempty"` +} + type ResendEmailInvitationRequest struct { RedirectURL string `json:"redirectURL,omitempty"` } @@ -307,6 +334,154 @@ func (c *ClientsListCall) Do() (*ClientPage, error) { } +// method id "dex.Client.Revoke": + +type ClientsRevokeCall struct { + s *Service + userid string + clientid string + opt_ map[string]interface{} +} + +// Revoke: Revoke all refresh tokens issues to the client for the +// authenticated user. +func (r *ClientsService) Revoke(userid string, clientid string) *ClientsRevokeCall { + c := &ClientsRevokeCall{s: r.s, opt_: make(map[string]interface{})} + c.userid = userid + c.clientid = clientid + return c +} + +// Fields allows partial responses to be retrieved. +// See https://developers.google.com/gdata/docs/2.0/basics#PartialResponse +// for more information. +func (c *ClientsRevokeCall) Fields(s ...googleapi.Field) *ClientsRevokeCall { + c.opt_["fields"] = googleapi.CombineFields(s) + return c +} + +func (c *ClientsRevokeCall) Do() error { + var body io.Reader = nil + params := make(url.Values) + params.Set("alt", "json") + if v, ok := c.opt_["fields"]; ok { + params.Set("fields", fmt.Sprintf("%v", v)) + } + urls := googleapi.ResolveRelative(c.s.BasePath, "account/{userid}/refresh/{clientid}") + urls += "?" + params.Encode() + req, _ := http.NewRequest("DELETE", urls, body) + googleapi.Expand(req.URL, map[string]string{ + "userid": c.userid, + "clientid": c.clientid, + }) + req.Header.Set("User-Agent", "google-api-go-client/0.5") + res, err := c.s.client.Do(req) + if err != nil { + return err + } + defer googleapi.CloseBody(res) + if err := googleapi.CheckResponse(res); err != nil { + return err + } + return nil + // { + // "description": "Revoke all refresh tokens issues to the client for the authenticated user.", + // "httpMethod": "DELETE", + // "id": "dex.Client.Revoke", + // "parameterOrder": [ + // "userid", + // "clientid" + // ], + // "parameters": { + // "clientid": { + // "location": "path", + // "required": true, + // "type": "string" + // }, + // "userid": { + // "location": "path", + // "required": true, + // "type": "string" + // } + // }, + // "path": "account/{userid}/refresh/{clientid}" + // } + +} + +// method id "dex.Client.List": + +type RefreshClientListCall struct { + s *Service + userid string + opt_ map[string]interface{} +} + +// List: List all clients that hold refresh tokens for the authenticated +// user. +func (r *RefreshClientService) List(userid string) *RefreshClientListCall { + c := &RefreshClientListCall{s: r.s, opt_: make(map[string]interface{})} + c.userid = userid + return c +} + +// Fields allows partial responses to be retrieved. +// See https://developers.google.com/gdata/docs/2.0/basics#PartialResponse +// for more information. +func (c *RefreshClientListCall) Fields(s ...googleapi.Field) *RefreshClientListCall { + c.opt_["fields"] = googleapi.CombineFields(s) + return c +} + +func (c *RefreshClientListCall) Do() (*RefreshClientList, error) { + var body io.Reader = nil + params := make(url.Values) + params.Set("alt", "json") + if v, ok := c.opt_["fields"]; ok { + params.Set("fields", fmt.Sprintf("%v", v)) + } + urls := googleapi.ResolveRelative(c.s.BasePath, "account/{userid}/refresh") + urls += "?" + params.Encode() + req, _ := http.NewRequest("GET", urls, body) + googleapi.Expand(req.URL, map[string]string{ + "userid": c.userid, + }) + req.Header.Set("User-Agent", "google-api-go-client/0.5") + res, err := c.s.client.Do(req) + if err != nil { + return nil, err + } + defer googleapi.CloseBody(res) + if err := googleapi.CheckResponse(res); err != nil { + return nil, err + } + var ret *RefreshClientList + if err := json.NewDecoder(res.Body).Decode(&ret); err != nil { + return nil, err + } + return ret, nil + // { + // "description": "List all clients that hold refresh tokens for the authenticated user.", + // "httpMethod": "GET", + // "id": "dex.Client.List", + // "parameterOrder": [ + // "userid" + // ], + // "parameters": { + // "userid": { + // "location": "path", + // "required": true, + // "type": "string" + // } + // }, + // "path": "account/{userid}/refresh", + // "response": { + // "$ref": "RefreshClientList" + // } + // } + +} + // method id "dex.User.Create": type UsersCreateCall struct { diff --git a/schema/workerschema/v1-json.go b/schema/workerschema/v1-json.go index 4c1e1ba8..9e1eae21 100644 --- a/schema/workerschema/v1-json.go +++ b/schema/workerschema/v1-json.go @@ -55,6 +55,37 @@ const DiscoveryJSON = `{ } } }, + "RefreshClient": { + "id": "Client", + "type": "object", + "description": "A client with associated public metadata.", + "properties": { + "clientID": { + "type": "string" + }, + "clientName": { + "type": "string" + }, + "logoURI": { + "type": "string" + }, + "clientURI": { + "type": "string" + } + } + }, + "RefreshClientList": { + "id": "RefreshClientList", + "type": "object", + "properties": { + "clients": { + "type": "array", + "items": { + "$ref": "RefreshClient" + } + } + } + }, "ClientWithSecret": { "id": "Client", "type": "object", @@ -241,6 +272,27 @@ const DiscoveryJSON = `{ "response": { "$ref": "ClientWithSecret" } + }, + "Revoke": { + "id": "dex.Client.Revoke", + "description": "Revoke all refresh tokens issues to the client for the authenticated user.", + "httpMethod": "DELETE", + "path": "account/{userid}/refresh/{clientid}", + "parameterOrder": [ + "userid","clientid" + ], + "parameters": { + "clientid": { + "type": "string", + "required": true, + "location": "path" + }, + "userid": { + "type": "string", + "required": true, + "location": "path" + } + } } } }, @@ -341,6 +393,29 @@ const DiscoveryJSON = `{ } } } + }, + "RefreshClient": { + "methods": { + "List": { + "id": "dex.Client.List", + "description": "List all clients that hold refresh tokens for the authenticated user.", + "httpMethod": "GET", + "path": "account/{userid}/refresh", + "parameters": { + "userid": { + "type": "string", + "required": true, + "location": "path" + } + }, + "parameterOrder": [ + "userid" + ], + "response": { + "$ref": "RefreshClientList" + } + } + } } } } diff --git a/schema/workerschema/v1.json b/schema/workerschema/v1.json index 1674119b..80343d1e 100644 --- a/schema/workerschema/v1.json +++ b/schema/workerschema/v1.json @@ -49,6 +49,37 @@ } } }, + "RefreshClient": { + "id": "Client", + "type": "object", + "description": "A client with associated public metadata.", + "properties": { + "clientID": { + "type": "string" + }, + "clientName": { + "type": "string" + }, + "logoURI": { + "type": "string" + }, + "clientURI": { + "type": "string" + } + } + }, + "RefreshClientList": { + "id": "RefreshClientList", + "type": "object", + "properties": { + "clients": { + "type": "array", + "items": { + "$ref": "RefreshClient" + } + } + } + }, "ClientWithSecret": { "id": "Client", "type": "object", @@ -235,6 +266,27 @@ "response": { "$ref": "ClientWithSecret" } + }, + "Revoke": { + "id": "dex.Client.Revoke", + "description": "Revoke all refresh tokens issues to the client for the authenticated user.", + "httpMethod": "DELETE", + "path": "account/{userid}/refresh/{clientid}", + "parameterOrder": [ + "userid","clientid" + ], + "parameters": { + "clientid": { + "type": "string", + "required": true, + "location": "path" + }, + "userid": { + "type": "string", + "required": true, + "location": "path" + } + } } } }, @@ -335,6 +387,29 @@ } } } + }, + "RefreshClient": { + "methods": { + "List": { + "id": "dex.Client.List", + "description": "List all clients that hold refresh tokens for the authenticated user.", + "httpMethod": "GET", + "path": "account/{userid}/refresh", + "parameters": { + "userid": { + "type": "string", + "required": true, + "location": "path" + } + }, + "parameterOrder": [ + "userid" + ], + "response": { + "$ref": "RefreshClientList" + } + } + } } } } diff --git a/server/config.go b/server/config.go index 6900d120..24e45000 100644 --- a/server/config.go +++ b/server/config.go @@ -164,6 +164,7 @@ func (cfg *SingleServerConfig) Configure(srv *Server) error { srv.SessionManager = sm srv.RefreshTokenRepo = refTokRepo srv.HealthChecks = append(srv.HealthChecks, db.NewHealthChecker(dbMap)) + srv.dbMap = dbMap return nil } @@ -290,6 +291,7 @@ func (cfg *MultiServerConfig) Configure(srv *Server) error { srv.SessionManager = sm srv.RefreshTokenRepo = refreshTokenRepo srv.HealthChecks = append(srv.HealthChecks, db.NewHealthChecker(dbc)) + srv.dbMap = dbc return nil } diff --git a/server/server.go b/server/server.go index f6669c5e..afc2a2e5 100644 --- a/server/server.go +++ b/server/server.go @@ -15,6 +15,7 @@ import ( "github.com/coreos/go-oidc/oauth2" "github.com/coreos/go-oidc/oidc" "github.com/coreos/pkg/health" + "github.com/go-gorp/gorp" "github.com/jonboulle/clockwork" "github.com/coreos/dex/client" @@ -77,6 +78,7 @@ type Server struct { EnableRegistration bool EnableClientRegistration bool + dbMap *gorp.DbMap localConnectorID string } @@ -257,12 +259,10 @@ func (s *Server) HTTPHandler() http.Handler { clientPath, clientHandler := registerClientResource(apiBasePath, s.ClientIdentityRepo) mux.Handle(path.Join(apiBasePath, clientPath), s.NewClientTokenAuthHandler(clientHandler)) - usersAPI := usersapi.NewUsersAPI(s.UserManager, s.ClientIdentityRepo, s.UserEmailer, s.localConnectorID) + usersAPI := usersapi.NewUsersAPI(s.dbMap, s.UserManager, s.UserEmailer, s.localConnectorID) handler := NewUserMgmtServer(usersAPI, s.JWTVerifierFactory(), s.UserManager, s.ClientIdentityRepo).HTTPHandler() - path := path.Join(apiBasePath, UsersSubTree) - mux.Handle(path, handler) - mux.Handle(path+"/", handler) + mux.Handle(apiBasePath+"/", handler) return http.Handler(mux) } diff --git a/server/user.go b/server/user.go index 8481ee3f..964afe91 100644 --- a/server/user.go +++ b/server/user.go @@ -30,6 +30,9 @@ var ( UsersGetEndpoint = addBasePath(UsersSubTree + "/:id") UsersDisableEndpoint = addBasePath(UsersSubTree + "/:id/disable") UsersResendInvitationEndpoint = addBasePath(UsersSubTree + "/:id/resend-invitation") + AccountSubTree = "/account" + AccountListRefreshTokens = addBasePath(AccountSubTree + "/:userid/refresh") + AccountRevokeRefreshToken = addBasePath(AccountSubTree + "/:userid/refresh/:clientid") ) type UserMgmtServer struct { @@ -52,26 +55,48 @@ func (s *UserMgmtServer) HTTPHandler() http.Handler { r := httprouter.New() r.RedirectTrailingSlash = false r.RedirectFixedPath = false - r.GET(UsersListEndpoint, s.authAPIHandle(s.listUsers)) - r.POST(UsersCreateEndpoint, s.authAPIHandle(s.createUser)) - r.POST(UsersDisableEndpoint, s.authAPIHandle(s.disableUser)) - r.GET(UsersGetEndpoint, s.authAPIHandle(s.getUser)) - r.POST(UsersResendInvitationEndpoint, s.authAPIHandle(s.resendInvitationEmail)) + + r.GET(UsersListEndpoint, s.authAdminUser(s.listUsers)) + r.POST(UsersCreateEndpoint, s.authAdminUser(s.createUser)) + r.POST(UsersDisableEndpoint, s.authAdminUser(s.disableUser)) + r.GET(UsersGetEndpoint, s.authAdminUser(s.getUser)) + r.POST(UsersResendInvitationEndpoint, s.authAdminUser(s.resendInvitationEmail)) + + r.GET(AccountListRefreshTokens, s.authAccount(s.listClientsWithRefreshTokens)) + r.DELETE(AccountRevokeRefreshToken, s.authAccount(s.revokeRefreshTokensForClient)) return r } +func (s *UserMgmtServer) authAdminUser(handle authedHandle) httprouter.Handle { + return s.authAPIHandle(handle, true) +} + +func (s *UserMgmtServer) authAccount(handle authedHandle) httprouter.Handle { + return s.authAPIHandle(handle, false) +} + // authedHandle is an HTTP handle which requires requests to be authenticated as an admin user. type authedHandle func(w http.ResponseWriter, r *http.Request, ps httprouter.Params, creds api.Creds) // authAPIHandle is a middleware function with authenticates an HTTP request before passing // it along to the authedHandle. -func (s *UserMgmtServer) authAPIHandle(handle authedHandle) httprouter.Handle { +// +// The authorization checks for an ID token bearer token in the request header, requiring the +// audience (aud claim) be a client ID of an admin client. +// +// If requiresAdmin is true, the subject identifier (sub claim) of the ID token provided must be +// that of an admin user. +func (s *UserMgmtServer) authAPIHandle(handle authedHandle, requiresAdmin bool) httprouter.Handle { return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { creds, err := s.getCreds(r) if err != nil { s.writeError(w, err) return } + if creds.User.Disabled || (requiresAdmin && !creds.User.Admin) { + s.writeError(w, api.ErrorUnauthorized) + return + } handle(w, r, ps, creds) } } @@ -191,6 +216,23 @@ func (s *UserMgmtServer) resendInvitationEmail(w http.ResponseWriter, r *http.Re writeResponseWithBody(w, http.StatusOK, resendEmailInvitationResponse) } +func (s *UserMgmtServer) listClientsWithRefreshTokens(w http.ResponseWriter, r *http.Request, ps httprouter.Params, creds api.Creds) { + clients, err := s.api.ListClientsWithRefreshTokens(creds, ps.ByName("userid")) + if err != nil { + s.writeError(w, err) + return + } + writeResponseWithBody(w, http.StatusOK, schema.RefreshClientList{Clients: clients}) +} + +func (s *UserMgmtServer) revokeRefreshTokensForClient(w http.ResponseWriter, r *http.Request, ps httprouter.Params, creds api.Creds) { + if err := s.api.RevokeRefreshTokensForClient(creds, ps.ByName("userid"), ps.ByName("clientid")); err != nil { + s.writeError(w, err) + return + } + w.WriteHeader(http.StatusOK) // NOTE (ericchiang): http.StatusNoContent or return an empty JSON object? +} + func (s *UserMgmtServer) writeError(w http.ResponseWriter, err error) { log.Errorf("Error calling user management API: %v: ", err) if apiErr, ok := err.(api.Error); ok { diff --git a/user/api/api.go b/user/api/api.go index 9eca4b38..35e0e907 100644 --- a/user/api/api.go +++ b/user/api/api.go @@ -9,8 +9,12 @@ import ( "net/url" "time" + "github.com/go-gorp/gorp" + "github.com/coreos/dex/client" + "github.com/coreos/dex/db" "github.com/coreos/dex/pkg/log" + "github.com/coreos/dex/refresh" schema "github.com/coreos/dex/schema/workerschema" "github.com/coreos/dex/user" "github.com/coreos/dex/user/manager" @@ -87,6 +91,7 @@ type UsersAPI struct { manager *manager.UserManager localConnectorID string clientIdentityRepo client.ClientIdentityRepo + refreshRepo refresh.RefreshTokenRepo emailer Emailer } @@ -99,10 +104,12 @@ type Creds struct { User user.User } -func NewUsersAPI(manager *manager.UserManager, cir client.ClientIdentityRepo, emailer Emailer, localConnectorID string) *UsersAPI { +// TODO(ericchiang): Don't pass a dbMap. See #385. +func NewUsersAPI(dbMap *gorp.DbMap, userManager *manager.UserManager, emailer Emailer, localConnectorID string) *UsersAPI { return &UsersAPI{ - manager: manager, - clientIdentityRepo: cir, + manager: userManager, + refreshRepo: db.NewRefreshTokenRepo(dbMap), + clientIdentityRepo: db.NewClientIdentityRepo(dbMap), localConnectorID: localConnectorID, emailer: emailer, } @@ -258,6 +265,47 @@ func (u *UsersAPI) ListUsers(creds Creds, maxResults int, nextPageToken string) return list, tok, nil } +// ListClientsWithRefreshTokens returns all clients issued refresh tokens +// for the authenticated user. +func (u *UsersAPI) ListClientsWithRefreshTokens(creds Creds, userID string) ([]*schema.RefreshClient, error) { + // Users must either be an admin or be requesting data associated with their own account. + if !creds.User.Admin && (creds.User.ID != userID) { + return nil, ErrorUnauthorized + } + clientIdentities, err := u.refreshRepo.ClientsWithRefreshTokens(userID) + if err != nil { + return nil, err + } + clients := make([]*schema.RefreshClient, len(clientIdentities)) + + urlToString := func(u *url.URL) string { + if u == nil { + return "" + } + return u.String() + } + + for i, identity := range clientIdentities { + clients[i] = &schema.RefreshClient{ + ClientID: identity.Credentials.ID, + ClientName: identity.Metadata.ClientName, + ClientURI: urlToString(identity.Metadata.ClientURI), + LogoURI: urlToString(identity.Metadata.LogoURI), + } + } + return clients, nil +} + +// RevokeClient revokes all refresh tokens issued to this client for the +// authenticiated user. +func (u *UsersAPI) RevokeRefreshTokensForClient(creds Creds, userID, clientID string) error { + // Users must either be an admin or be requesting data associated with their own account. + if !creds.User.Admin && (creds.User.ID != userID) { + return ErrorUnauthorized + } + return u.refreshRepo.RevokeTokensForClient(userID, clientID) +} + func (u *UsersAPI) Authorize(creds Creds) bool { return creds.User.Admin && !creds.User.Disabled } diff --git a/user/api/api_test.go b/user/api/api_test.go index 5412cb2d..de5b62d0 100644 --- a/user/api/api_test.go +++ b/user/api/api_test.go @@ -3,6 +3,7 @@ package api import ( "encoding/base64" "net/url" + "sort" "testing" "time" @@ -10,7 +11,6 @@ import ( "github.com/jonboulle/clockwork" "github.com/kylelemons/godebug/pretty" - "github.com/coreos/dex/client" "github.com/coreos/dex/connector" "github.com/coreos/dex/db" schema "github.com/coreos/dex/schema/workerschema" @@ -166,16 +166,27 @@ func makeTestFixtures() (*UsersAPI, *testEmailer) { }, }, } - cir := func() client.ClientIdentityRepo { - repo, err := db.NewClientIdentityRepoFromClients(db.NewMemDB(), []oidc.ClientIdentity{ci}) - if err != nil { - panic("Failed to create client identity repo: " + err.Error()) + if _, err := db.NewClientIdentityRepoFromClients(dbMap, []oidc.ClientIdentity{ci}); err != nil { + panic("Failed to create client identity repo: " + err.Error()) + } + + // Used in TestRevokeRefreshToken test. + refreshTokens := []struct { + clientID string + userID string + }{ + {"XXX", "ID-1"}, + {"XXX", "ID-2"}, + } + refreshRepo := db.NewRefreshTokenRepo(dbMap) + for _, token := range refreshTokens { + if _, err := refreshRepo.Create(token.userID, token.clientID); err != nil { + panic("Failed to create refresh token: " + err.Error()) } - return repo - }() + } emailer := &testEmailer{} - api := NewUsersAPI(mgr, cir, emailer, "local") + api := NewUsersAPI(dbMap, mgr, emailer, "local") return api, emailer } @@ -562,3 +573,57 @@ func TestResendEmailInvitation(t *testing.T) { } } } + +func TestRevokeRefreshToken(t *testing.T) { + tests := []struct { + userID string + toRevoke string + before []string // clientIDs expected before the change. + after []string // clientIDs expected after the change. + }{ + {"ID-1", "XXX", []string{"XXX"}, []string{}}, + {"ID-2", "XXX", []string{"XXX"}, []string{}}, + } + + api, _ := makeTestFixtures() + + listClientsWithRefreshTokens := func(creds Creds, userID string) ([]string, error) { + clients, err := api.ListClientsWithRefreshTokens(creds, userID) + if err != nil { + return nil, err + } + clientIDs := make([]string, len(clients)) + for i, client := range clients { + clientIDs[i] = client.ClientID + } + sort.Strings(clientIDs) + return clientIDs, nil + } + + for i, tt := range tests { + creds := Creds{User: user.User{ID: tt.userID}} + + gotBefore, err := listClientsWithRefreshTokens(creds, tt.userID) + if err != nil { + t.Errorf("case %d: list clients failed: %v", i, err) + } else { + if diff := pretty.Compare(tt.before, gotBefore); diff != "" { + t.Errorf("case %d: before exp!=got: %s", i, diff) + } + } + + if err := api.RevokeRefreshTokensForClient(creds, tt.userID, tt.toRevoke); err != nil { + t.Errorf("case %d: failed to revoke client: %v", i, err) + continue + } + + gotAfter, err := listClientsWithRefreshTokens(creds, tt.userID) + if err != nil { + t.Errorf("case %d: list clients failed: %v", i, err) + } else { + if diff := pretty.Compare(tt.after, gotAfter); diff != "" { + t.Errorf("case %d: after exp!=got: %s", i, diff) + } + } + } +}