package server import ( "encoding/json" "net/http" "net/url" "strconv" "github.com/coreos/go-oidc/jose" "github.com/coreos/go-oidc/oidc" "github.com/julienschmidt/httprouter" clientmanager "github.com/coreos/dex/client/manager" "github.com/coreos/dex/pkg/log" schema "github.com/coreos/dex/schema/workerschema" "github.com/coreos/dex/user" "github.com/coreos/dex/user/api" usermanager "github.com/coreos/dex/user/manager" ) const ( defaultMaxResults = 20 ) var ( UsersSubTree = "/users" UsersListEndpoint = addBasePath(UsersSubTree) UsersCreateEndpoint = addBasePath(UsersSubTree) 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 { api *api.UsersAPI jwtvFactory JWTVerifierFactory um *usermanager.UserManager cm *clientmanager.ClientManager } func NewUserMgmtServer(userMgmtAPI *api.UsersAPI, jwtvFactory JWTVerifierFactory, um *usermanager.UserManager, cm *clientmanager.ClientManager) *UserMgmtServer { return &UserMgmtServer{ api: userMgmtAPI, jwtvFactory: jwtvFactory, um: um, cm: cm, } } func (s *UserMgmtServer) HTTPHandler() http.Handler { r := httprouter.New() r.RedirectTrailingSlash = false r.RedirectFixedPath = false 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. // // 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, requiresAdmin) 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) } } func (s *UserMgmtServer) listUsers(w http.ResponseWriter, r *http.Request, ps httprouter.Params, creds api.Creds) { nextPageToken := r.URL.Query().Get("nextPageToken") maxResults, err := intFromQuery(r.URL.Query(), "maxResults", defaultMaxResults) if err != nil { writeAPIError(w, http.StatusBadRequest, newAPIError(errorInvalidRequest, "maxResults must be an integer")) return } users, nextPageToken, err := s.api.ListUsers(creds, maxResults, nextPageToken) if err != nil { s.writeError(w, err) return } usersResponse := schema.UsersResponse{ Users: users, NextPageToken: nextPageToken, } writeResponseWithBody(w, http.StatusOK, usersResponse) } func (s *UserMgmtServer) getUser(w http.ResponseWriter, r *http.Request, ps httprouter.Params, creds api.Creds) { id := ps.ByName("id") if id == "" { writeAPIError(w, http.StatusBadRequest, newAPIError(errorInvalidRequest, "id is required")) return } usr, err := s.api.GetUser(creds, id) if err != nil { s.writeError(w, err) return } userResponse := schema.UserResponse{ User: &usr, } writeResponseWithBody(w, http.StatusOK, userResponse) } func (s *UserMgmtServer) createUser(w http.ResponseWriter, r *http.Request, ps httprouter.Params, creds api.Creds) { createReq := schema.UserCreateRequest{} if err := json.NewDecoder(r.Body).Decode(&createReq); err != nil { writeInvalidRequest(w, "cannot parse JSON body") return } redirURL, err := url.Parse(createReq.RedirectURL) if err != nil { writeAPIError(w, http.StatusBadRequest, newAPIError(errorInvalidRequest, "redirectURL must be a valid URL")) return } createdResponse, err := s.api.CreateUser(creds, *createReq.User, *redirURL) if err != nil { s.writeError(w, err) return } writeResponseWithBody(w, http.StatusOK, createdResponse) } func (s *UserMgmtServer) disableUser(w http.ResponseWriter, r *http.Request, ps httprouter.Params, creds api.Creds) { id := ps.ByName("id") if id == "" { writeAPIError(w, http.StatusBadRequest, newAPIError(errorInvalidRequest, "id is required")) return } disableReq := schema.UserDisableRequest{} if err := json.NewDecoder(r.Body).Decode(&disableReq); err != nil { writeInvalidRequest(w, "cannot parse JSON body") } resp, err := s.api.DisableUser(creds, id, disableReq.Disable) if err != nil { s.writeError(w, err) return } writeResponseWithBody(w, http.StatusOK, resp) } func (s *UserMgmtServer) resendInvitationEmail(w http.ResponseWriter, r *http.Request, ps httprouter.Params, creds api.Creds) { id := ps.ByName("id") if id == "" { writeAPIError(w, http.StatusBadRequest, newAPIError(errorInvalidRequest, "id is required")) return } resendEmailInvitationReq := schema.ResendEmailInvitationRequest{} if err := json.NewDecoder(r.Body).Decode(&resendEmailInvitationReq); err != nil { writeInvalidRequest(w, "cannot parse JSON body") return } redirURL, err := url.Parse(resendEmailInvitationReq.RedirectURL) if err != nil { writeAPIError(w, http.StatusBadRequest, newAPIError(errorInvalidRequest, "redirectURL must be a valid URL")) return } resendEmailInvitationResponse, err := s.api.ResendEmailInvitation(creds, id, *redirURL) if err != nil { s.writeError(w, err) return } 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 { writeAPIError(w, apiErr.Code, newAPIError(apiErr.Type, apiErr.Desc)) return } writeAPIError(w, http.StatusInternalServerError, newAPIError(errorServerError, err.Error())) } func (s *UserMgmtServer) getCreds(r *http.Request, requiresAdmin bool) (api.Creds, error) { token, err := oidc.ExtractBearerToken(r) if err != nil { log.Errorf("userMgmtServer: GetCreds err: %q", err) return api.Creds{}, api.ErrorUnauthorized } jwt, err := jose.ParseJWT(token) if err != nil { log.Errorf("userMgmtServer: GetCreds err: %q", err) return api.Creds{}, api.ErrorUnauthorized } claims, err := jwt.Claims() if err != nil { log.Errorf("userMgmtServer: GetCreds err: %q", err) return api.Creds{}, api.ErrorUnauthorized } // The "aud" claim is allowed to be both a list of clients or a single client. Check for both cases. clientIDs, ok, err := claims.StringsClaim("aud") if err != nil || !ok { clientID, ok, err := claims.StringClaim("aud") if err != nil { log.Errorf("userMgmtServer: GetCreds failed to parse 'aud' claim: %q", err) return api.Creds{}, api.ErrorUnauthorized } if !ok || clientID == "" { return api.Creds{}, api.ErrorUnauthorized } clientIDs = []string{clientID} } if len(clientIDs) == 0 { log.Errorf("userMgmtServer: GetCreds err: no client in audience") return api.Creds{}, api.ErrorUnauthorized } // Verify that the JWT is signed by this server, has the correct issuer, hasn't expired, etc. // While we don't actualy care which client the token was issued for (we'll check that later), // go-oidc doesn't provide any methods which don't require passing a client ID. // // TODO(ericchiang): Add a verifier to go-oidc that doesn't require a client ID. verifier := s.jwtvFactory(clientIDs[0]) if err := verifier.Verify(jwt); err != nil { log.Errorf("userMgmtServer: GetCreds err: failed to verify token %q", err) return api.Creds{}, api.ErrorUnauthorized } sub, ok, err := claims.StringClaim("sub") if err != nil { log.Errorf("userMgmtServer: GetCreds err: %q", err) return api.Creds{}, err } if !ok || sub == "" { return api.Creds{}, api.ErrorUnauthorized } usr, err := s.um.Get(sub) if err != nil { if err == user.ErrorNotFound { return api.Creds{}, api.ErrorUnauthorized } log.Errorf("userMgmtServer: GetCreds err: %q", err) return api.Creds{}, err } i := 0 for _, clientID := range clientIDs { // Make sure the client actually exists. isAdmin, err := s.cm.IsDexAdmin(clientID) if err != nil { log.Errorf("userMgmtServer: GetCreds err: failed to get client %v", err) return api.Creds{}, err } // If the endpoint requires an admin client, filter out clients which are not admins. if requiresAdmin && !isAdmin { continue } clientIDs[i] = clientID i++ } clientIDs = clientIDs[:i] if len(clientIDs) == 0 { return api.Creds{}, api.ErrorForbidden } return api.Creds{ ClientIDs: clientIDs, User: usr, }, nil } func intFromQuery(ps url.Values, name string, defaultVal int) (int, error) { s := ps.Get(name) if s == "" { return defaultVal, nil } return strconv.Atoi(s) }