From de8608f99fb2c1a47ad6366c573518ef3b1ca0ad Mon Sep 17 00:00:00 2001 From: Pascal Fischer Date: Tue, 21 Mar 2023 16:02:19 +0100 Subject: [PATCH] add rest endpoints and update openapi doc --- management/server/http/api/openapi.yml | 176 +++++++++++++++++++ management/server/http/api/types.gen.go | 33 ++++ management/server/http/handler.go | 9 + management/server/http/pat_handler.go | 187 +++++++++++++++++++++ management/server/http/pat_handler_test.go | 37 ++++ 5 files changed, 442 insertions(+) create mode 100644 management/server/http/pat_handler.go create mode 100644 management/server/http/pat_handler_test.go diff --git a/management/server/http/api/openapi.yml b/management/server/http/api/openapi.yml index b3d954a4d..3f742b850 100644 --- a/management/server/http/api/openapi.yml +++ b/management/server/http/api/openapi.yml @@ -6,6 +6,8 @@ info: tags: - name: Users description: Interact with and view information about users. + - name: Tokens + description: Interact with and view information about tokens. - name: Peers description: Interact with and view information about peers. - name: Setup Keys @@ -284,6 +286,53 @@ components: - revoked - auto_groups - usage_limit + PersonalAccessToken: + type: object + properties: + id: + description: ID of a token + type: string + description: + description: Description of the token + type: string +# hashed_token: +# description: Hashed representation of the token +# type: string + expiration_date: + description: Date the token expires + type: string + format: date-time + created_by: + description: User ID of the user who created the token + type: string + created_at: + description: Date the token was created + type: string + format: date-time + last_used: + description: Date the token was last used + type: string + format: date-time + required: + - id + - description +# - hashed_token + - expiration_date + - created_by + - created_at + - last_used + PersonalAccessTokenRequest: + type: object + properties: + description: + description: Description of the token + type: string + expires_in: + description: Expiration in days + type: integer + required: + - description + - expires_in GroupMinimum: type: object properties: @@ -848,6 +897,133 @@ paths: "$ref": "#/components/responses/forbidden" '500': "$ref": "#/components/responses/internal_error" + /api/users/{userId}/tokens: + get: + summary: Returns a list of all tokens for a user + tags: [ Tokens ] + security: + - BearerAuth: [] + parameters: + - in: path + name: userId + required: true + schema: + type: string + description: The User ID + responses: + '200': + description: A JSON Array of PersonalAccessTokens + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/PersonalAccessToken' + '400': + "$ref": "#/components/responses/bad_request" + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '500': + "$ref": "#/components/responses/internal_error" + post: + summary: Create a new token + tags: [ Tokens ] + security: + - BearerAuth: [ ] + parameters: + - in: path + name: userId + required: true + schema: + type: string + description: The User ID + requestBody: + description: PersonalAccessToken create parameters + content: + application/json: + schema: + $ref: '#/components/schemas/PersonalAccessTokenRequest' + responses: + '200': + description: The token in plain text + content: + text/plain: + schema: + type: string + '400': + "$ref": "#/components/responses/bad_request" + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '500': + "$ref": "#/components/responses/internal_error" + /api/users/{userId}/tokens/{tokenId}: + get: + summary: Returns a specific token + tags: [ Tokens ] + security: + - BearerAuth: [ ] + parameters: + - in: path + name: userId + required: true + schema: + type: string + description: The User ID + - in: path + name: tokenId + required: true + schema: + type: string + description: The Token ID + responses: + '200': + description: A PersonalAccessTokens Object + content: + application/json: + schema: + $ref: '#/components/schemas/PersonalAccessToken' + '400': + "$ref": "#/components/responses/bad_request" + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '500': + "$ref": "#/components/responses/internal_error" + delete: + summary: Delete a token + tags: [ Tokens ] + security: + - BearerAuth: [ ] + parameters: + - in: path + name: userId + required: true + schema: + type: string + description: The User ID + - in: path + name: tokenId + required: true + schema: + type: string + description: The Token ID + responses: + '200': + description: Delete status code + content: { } + '400': + "$ref": "#/components/responses/bad_request" + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '500': + "$ref": "#/components/responses/internal_error" /api/peers: get: summary: Returns a list of all peers diff --git a/management/server/http/api/types.gen.go b/management/server/http/api/types.gen.go index 372ecd1a7..76c128d55 100644 --- a/management/server/http/api/types.gen.go +++ b/management/server/http/api/types.gen.go @@ -379,6 +379,36 @@ type PeerMinimum struct { Name string `json:"name"` } +// PersonalAccessToken defines model for PersonalAccessToken. +type PersonalAccessToken struct { + // CreatedAt Date the token was created + CreatedAt time.Time `json:"created_at"` + + // CreatedBy User ID of the user who created the token + CreatedBy string `json:"created_by"` + + // Description Description of the token + Description string `json:"description"` + + // ExpirationDate Date the token expires + ExpirationDate time.Time `json:"expiration_date"` + + // Id ID of a token + Id string `json:"id"` + + // LastUsed Date the token was last used + LastUsed time.Time `json:"last_used"` +} + +// PersonalAccessTokenRequest defines model for PersonalAccessTokenRequest. +type PersonalAccessTokenRequest struct { + // Description Description of the token + Description string `json:"description"` + + // ExpiresIn Expiration in days + ExpiresIn int `json:"expires_in"` +} + // Policy defines model for Policy. type Policy struct { // Description Policy friendly description @@ -808,3 +838,6 @@ type PostApiUsersJSONRequestBody = UserCreateRequest // PutApiUsersIdJSONRequestBody defines body for PutApiUsersId for application/json ContentType. type PutApiUsersIdJSONRequestBody = UserRequest + +// PostApiUsersUserIdTokensJSONRequestBody defines body for PostApiUsersUserIdTokens for application/json ContentType. +type PostApiUsersUserIdTokensJSONRequestBody = PersonalAccessTokenRequest diff --git a/management/server/http/handler.go b/management/server/http/handler.go index 90f62e700..e2ed927a3 100644 --- a/management/server/http/handler.go +++ b/management/server/http/handler.go @@ -57,6 +57,7 @@ func APIHandler(accountManager s.AccountManager, appMetrics telemetry.AppMetrics api.addAccountsEndpoint() api.addPeersEndpoint() api.addUsersEndpoint() + api.addUsersTokensEndpoint() api.addSetupKeysEndpoint() api.addRulesEndpoint() api.addPoliciesEndpoint() @@ -110,6 +111,14 @@ func (apiHandler *apiHandler) addUsersEndpoint() { apiHandler.Router.HandleFunc("/users", userHandler.CreateUser).Methods("POST", "OPTIONS") } +func (apiHandler *apiHandler) addUsersTokensEndpoint() { + tokenHandler := NewPATsHandler(apiHandler.AccountManager, apiHandler.AuthCfg) + apiHandler.Router.HandleFunc("/users/{userId}/tokens", tokenHandler.GetAllTokens).Methods("GET", "OPTIONS") + apiHandler.Router.HandleFunc("/users/{userId}/tokens", tokenHandler.CreateToken).Methods("POST", "OPTIONS") + apiHandler.Router.HandleFunc("/users/{userId}/tokens/{tokenId}", tokenHandler.GetToken).Methods("GET", "OPTIONS") + apiHandler.Router.HandleFunc("/users/{userId}/tokens/{tokenId}", tokenHandler.DeleteToken).Methods("DELETE", "OPTIONS") +} + func (apiHandler *apiHandler) addSetupKeysEndpoint() { keysHandler := NewSetupKeysHandler(apiHandler.AccountManager, apiHandler.AuthCfg) apiHandler.Router.HandleFunc("/setup-keys", keysHandler.GetAllSetupKeys).Methods("GET", "OPTIONS") diff --git a/management/server/http/pat_handler.go b/management/server/http/pat_handler.go new file mode 100644 index 000000000..8cdef141a --- /dev/null +++ b/management/server/http/pat_handler.go @@ -0,0 +1,187 @@ +package http + +import ( + "encoding/json" + "net/http" + + "github.com/gorilla/mux" + + "github.com/netbirdio/netbird/management/server" + "github.com/netbirdio/netbird/management/server/http/api" + "github.com/netbirdio/netbird/management/server/http/util" + "github.com/netbirdio/netbird/management/server/jwtclaims" + "github.com/netbirdio/netbird/management/server/status" +) + +// PATHandler is the nameserver group handler of the account +type PATHandler struct { + accountManager server.AccountManager + claimsExtractor *jwtclaims.ClaimsExtractor +} + +func NewPATsHandler(accountManager server.AccountManager, authCfg AuthCfg) *PATHandler { + return &PATHandler{ + accountManager: accountManager, + claimsExtractor: jwtclaims.NewClaimsExtractor( + jwtclaims.WithAudience(authCfg.Audience), + jwtclaims.WithUserIDClaim(authCfg.UserIDClaim), + ), + } +} + +func (h *PATHandler) GetAllTokens(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + util.WriteErrorResponse("wrong HTTP method", http.StatusMethodNotAllowed, w) + return + } + + claims := h.claimsExtractor.FromRequestContext(r) + account, user, err := h.accountManager.GetAccountFromToken(claims) + if err != nil { + util.WriteError(err, w) + return + } + + vars := mux.Vars(r) + userID := vars["userId"] + if len(userID) == 0 { + util.WriteError(status.Errorf(status.InvalidArgument, "invalid user ID"), w) + return + } + if userID != user.Id { + util.WriteErrorResponse("User not authorized to get tokens", http.StatusUnauthorized, w) + return + } + + var pats []*api.PersonalAccessToken + for _, pat := range account.Users[userID].PATs { + pats = append(pats, toPATResponse(pat)) + } + + util.WriteJSONObject(w, pats) +} + +func (h *PATHandler) GetToken(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + util.WriteErrorResponse("wrong HTTP method", http.StatusMethodNotAllowed, w) + return + } + + claims := h.claimsExtractor.FromRequestContext(r) + account, user, err := h.accountManager.GetAccountFromToken(claims) + if err != nil { + util.WriteError(err, w) + return + } + + vars := mux.Vars(r) + userID := vars["userId"] + if len(userID) == 0 { + util.WriteError(status.Errorf(status.InvalidArgument, "invalid user ID"), w) + return + } + if userID != user.Id { + util.WriteErrorResponse("User not authorized to get token", http.StatusUnauthorized, w) + return + } + + tokenID := vars["tokenId"] + if len(tokenID) == 0 { + util.WriteError(status.Errorf(status.InvalidArgument, "invalid token ID"), w) + return + } + + pat := account.Users[userID].PATs[tokenID] + util.WriteJSONObject(w, toPATResponse(pat)) +} + +func (h *PATHandler) CreateToken(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + util.WriteErrorResponse("wrong HTTP method", http.StatusMethodNotAllowed, w) + return + } + + claims := h.claimsExtractor.FromRequestContext(r) + account, user, err := h.accountManager.GetAccountFromToken(claims) + if err != nil { + util.WriteError(err, w) + return + } + + vars := mux.Vars(r) + userID := vars["userId"] + if len(userID) == 0 { + util.WriteError(status.Errorf(status.InvalidArgument, "invalid user ID"), w) + return + } + if userID != user.Id { + util.WriteErrorResponse("User not authorized to create token", http.StatusUnauthorized, w) + return + } + + var req api.PostApiUsersUserIdTokensJSONRequestBody + err = json.NewDecoder(r.Body).Decode(&req) + if err != nil { + util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w) + return + } + + pat, plainToken, err := server.CreateNewPAT(req.Description, req.ExpiresIn, user.Id) + err = h.accountManager.AddPATToUser(account.Id, userID, pat) + if err != nil { + util.WriteError(err, w) + return + } + + util.WriteJSONObject(w, plainToken) +} + +func (h *PATHandler) DeleteToken(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + util.WriteErrorResponse("wrong HTTP method", http.StatusMethodNotAllowed, w) + return + } + + claims := h.claimsExtractor.FromRequestContext(r) + account, user, err := h.accountManager.GetAccountFromToken(claims) + if err != nil { + util.WriteError(err, w) + return + } + + vars := mux.Vars(r) + userID := vars["userId"] + if len(userID) == 0 { + util.WriteError(status.Errorf(status.InvalidArgument, "invalid user ID"), w) + return + } + if userID != user.Id { + util.WriteErrorResponse("User not authorized to delete token", http.StatusUnauthorized, w) + return + } + + tokenID := vars["tokenId"] + if len(tokenID) == 0 { + util.WriteError(status.Errorf(status.InvalidArgument, "invalid token ID"), w) + return + } + + err = h.accountManager.DeletePAT(account.Id, userID, tokenID) + if err != nil { + util.WriteError(err, w) + return + } + + util.WriteJSONObject(w, "") +} + +func toPATResponse(pat *server.PersonalAccessToken) *api.PersonalAccessToken { + return &api.PersonalAccessToken{ + CreatedAt: pat.CreatedAt, + CreatedBy: pat.CreatedBy, + Description: pat.Description, + ExpirationDate: pat.ExpirationDate, + Id: pat.ID, + LastUsed: pat.LastUsed, + } +} diff --git a/management/server/http/pat_handler_test.go b/management/server/http/pat_handler_test.go new file mode 100644 index 000000000..3d32e7c30 --- /dev/null +++ b/management/server/http/pat_handler_test.go @@ -0,0 +1,37 @@ +package http + +import ( + "net/http" + + "github.com/netbirdio/netbird/management/server" + "github.com/netbirdio/netbird/management/server/jwtclaims" + "github.com/netbirdio/netbird/management/server/mock_server" + "github.com/netbirdio/netbird/management/server/status" +) + +func initPATTestData() *PATHandler { + return &PATHandler{ + accountManager: &mock_server.MockAccountManager{ + + AddPATToUserFunc: func(accountID string, userID string, pat *server.PersonalAccessToken) error { + if nsGroupID == existingNSGroupID { + return baseExistingNSGroup.Copy(), nil + } + return nil, status.Errorf(status.NotFound, "nameserver group with ID %s not found", nsGroupID) + }, + + GetAccountFromTokenFunc: func(_ jwtclaims.AuthorizationClaims) (*server.Account, *server.User, error) { + return testingNSAccount, testingAccount.Users["test_user"], nil + }, + }, + claimsExtractor: jwtclaims.NewClaimsExtractor( + jwtclaims.WithFromRequestContext(func(r *http.Request) jwtclaims.AuthorizationClaims { + return jwtclaims.AuthorizationClaims{ + UserId: "test_user", + Domain: "hotmail.com", + AccountId: testNSGroupAccountID, + } + }), + ), + } +}