diff --git a/.gitignore b/.gitignore index a36d3a9..2ca2054 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,5 @@ ssh.key .testCoverage.txt wg_portal.db go.sum +swagger.json +swagger.yaml \ No newline at end of file diff --git a/Makefile b/Makefile index e7f1f60..c8f8483 100644 --- a/Makefile +++ b/Makefile @@ -51,6 +51,10 @@ docker-build: docker-push: docker push $(IMAGE) +api-docs: + cd internal/server; swag init --parseDependency --parseInternal --generalInfo api.go + $(GOCMD) fmt internal/server/docs/docs.go + $(BUILDDIR)/%-amd64: cmd/%/main.go dep phony GOOS=linux GOARCH=amd64 $(GOCMD) build -ldflags "-X github.com/h44z/wg-portal/internal/server.Version=${ENV_BUILD_IDENTIFIER}-${ENV_BUILD_VERSION}" -o $@ $< diff --git a/go.mod b/go.mod index 4524368..84611b0 100644 --- a/go.mod +++ b/go.mod @@ -4,21 +4,31 @@ go 1.16 require ( git.prolicht.digital/pub/healthcheck v1.0.1 + github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 + github.com/evanphx/json-patch v0.5.2 github.com/gin-contrib/sessions v0.0.3 github.com/gin-gonic/gin v1.6.3 github.com/go-ldap/ldap/v3 v3.2.4 + github.com/go-openapi/spec v0.20.3 // indirect + github.com/go-openapi/swag v0.19.15 // indirect github.com/go-playground/validator/v10 v10.4.1 github.com/gorilla/sessions v1.2.1 // indirect github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible github.com/kelseyhightower/envconfig v1.4.0 + github.com/mailru/easyjson v0.7.7 // indirect github.com/milosgajdos/tenus v0.0.3 github.com/pkg/errors v0.9.1 github.com/sirupsen/logrus v1.8.1 github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e + github.com/swaggo/gin-swagger v1.3.0 + github.com/swaggo/swag v1.7.0 github.com/tatsushid/go-fastping v0.0.0-20160109021039-d7bb493dee3e github.com/toorop/gin-logrus v0.0.0-20210225092905-2c785434f26f github.com/utrack/gin-csrf v0.0.0-20190424104817-40fb8d2c8fca golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2 + golang.org/x/net v0.0.0-20210423184538-5f58ad60dda6 // indirect + golang.org/x/sys v0.0.0-20210426080607-c94f62235c83 // indirect + golang.org/x/tools v0.1.0 // indirect golang.zx2c4.com/wireguard/wgctrl v0.0.0-20200609130330-bd2cb7843e1b gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b gorm.io/driver/mysql v1.0.5 diff --git a/internal/server/api.go b/internal/server/api.go new file mode 100644 index 0000000..3a0ed78 --- /dev/null +++ b/internal/server/api.go @@ -0,0 +1,265 @@ +package server + +// go get -u github.com/swaggo/swag/cmd/swag +// run: swag init --parseDependency --parseInternal --generalInfo api.go +// in the internal/server folder +import ( + "encoding/json" + "net/http" + "strings" + + jsonpatch "github.com/evanphx/json-patch" + "github.com/gin-gonic/gin" + "github.com/h44z/wg-portal/internal/users" +) + +// @title WireGuard Portal API +// @version 1.0 +// @description WireGuard Portal API for managing users and peers. + +// @license.name MIT +// @license.url https://github.com/h44z/wg-portal/blob/master/LICENSE.txt + +// @securityDefinitions.basic ApiBasicAuth +// @in header +// @name Authorization + +// @BasePath /api/v1 + +// ApiServer is a simple wrapper struct so that we can have fresh member function names. +type ApiServer struct { + s *Server +} + +type ApiError struct { + Message string +} + +// GetUsers godoc +// @Summary Retrieves all users +// @Produce json +// @Success 200 {object} []users.User +// @Failure 401 {object} ApiError +// @Failure 403 {object} ApiError +// @Failure 404 {object} ApiError +// @Router /users [get] +// @Security ApiBasicAuth +func (s *ApiServer) GetUsers(c *gin.Context) { + allUsers := s.s.users.GetUsersUnscoped() + for i := range allUsers { + allUsers[i].Password = "" // do not publish password... + } + + c.JSON(http.StatusOK, allUsers) +} + +// GetUser godoc +// @Summary Retrieves user based on given Email +// @Produce json +// @Param email path string true "User Email" +// @Success 200 {object} users.User +// @Failure 400 {object} ApiError +// @Failure 401 {object} ApiError +// @Failure 403 {object} ApiError +// @Failure 404 {object} ApiError +// @Router /user/{email} [get] +// @Security ApiBasicAuth +func (s *ApiServer) GetUser(c *gin.Context) { + email := strings.ToLower(strings.TrimSpace(c.Param("email"))) + + if email == "" { + c.JSON(http.StatusBadRequest, ApiError{Message: "email parameter must be specified"}) + return + } + user := s.s.users.GetUserUnscoped(c.Param("email")) + if user == nil { + c.JSON(http.StatusNotFound, ApiError{Message: "user not found"}) + return + } + user.Password = "" // do not send password... + c.JSON(http.StatusOK, user) +} + +// PostUser godoc +// @Summary Creates a new user based on the given user model +// @Produce json +// @Success 200 {object} users.User +// @Failure 400 {object} ApiError +// @Failure 401 {object} ApiError +// @Failure 403 {object} ApiError +// @Failure 404 {object} ApiError +// @Failure 500 {object} ApiError +// @Router /users [post] +// @Security ApiBasicAuth +func (s *ApiServer) PostUser(c *gin.Context) { + newUser := users.User{} + if err := c.BindJSON(&newUser); err != nil { + c.JSON(http.StatusBadRequest, ApiError{Message: err.Error()}) + return + } + + if user := s.s.users.GetUserUnscoped(newUser.Email); user != nil { + c.JSON(http.StatusBadRequest, ApiError{Message: "user already exists"}) + return + } + + if err := s.s.CreateUser(newUser, s.s.wg.Cfg.GetDefaultDeviceName()); err != nil { + c.JSON(http.StatusInternalServerError, ApiError{Message: err.Error()}) + return + } + + user := s.s.users.GetUserUnscoped(newUser.Email) + if user == nil { + c.JSON(http.StatusNotFound, ApiError{Message: "user not found"}) + return + } + user.Password = "" // do not send password... + c.JSON(http.StatusOK, user) +} + +// PutUser godoc +// @Summary Updates a user based on the given user model +// @Produce json +// @Param email path string true "User Email" +// @Success 200 {object} users.User +// @Failure 400 {object} ApiError +// @Failure 401 {object} ApiError +// @Failure 403 {object} ApiError +// @Failure 404 {object} ApiError +// @Failure 500 {object} ApiError +// @Router /user/{email} [put] +// @Security ApiBasicAuth +func (s *ApiServer) PutUser(c *gin.Context) { + email := strings.ToLower(strings.TrimSpace(c.Param("email"))) + if email == "" { + c.JSON(http.StatusBadRequest, ApiError{Message: "email parameter must be specified"}) + return + } + + updateUser := users.User{} + if err := c.BindJSON(&updateUser); err != nil { + c.JSON(http.StatusBadRequest, ApiError{Message: err.Error()}) + return + } + + // Changing email address is not allowed + if email != updateUser.Email { + c.JSON(http.StatusBadRequest, ApiError{Message: "email parameter must match the model email address"}) + return + } + + if user := s.s.users.GetUserUnscoped(email); user == nil { + c.JSON(http.StatusNotFound, ApiError{Message: "user does not exist"}) + return + } + + if err := s.s.UpdateUser(updateUser); err != nil { + c.JSON(http.StatusInternalServerError, ApiError{Message: err.Error()}) + return + } + + user := s.s.users.GetUserUnscoped(email) + if user == nil { + c.JSON(http.StatusNotFound, ApiError{Message: "user not found"}) + return + } + user.Password = "" // do not send password... + c.JSON(http.StatusOK, user) +} + +// PatchUser godoc +// @Summary Updates a user based on the given partial user model +// @Produce json +// @Param email path string true "User Email" +// @Success 200 {object} users.User +// @Failure 400 {object} ApiError +// @Failure 401 {object} ApiError +// @Failure 403 {object} ApiError +// @Failure 404 {object} ApiError +// @Failure 500 {object} ApiError +// @Router /user/{email} [patch] +// @Security ApiBasicAuth +func (s *ApiServer) PatchUser(c *gin.Context) { + email := strings.ToLower(strings.TrimSpace(c.Param("email"))) + if email == "" { + c.JSON(http.StatusBadRequest, ApiError{Message: "email parameter must be specified"}) + return + } + + patch, err := c.GetRawData() + if err != nil { + c.JSON(http.StatusBadRequest, ApiError{Message: err.Error()}) + return + } + + user := s.s.users.GetUserUnscoped(email) + if user == nil { + c.JSON(http.StatusNotFound, ApiError{Message: "user does not exist"}) + return + } + userData, err := json.Marshal(user) + if err != nil { + c.JSON(http.StatusInternalServerError, ApiError{Message: err.Error()}) + return + } + + mergedUserData, err := jsonpatch.MergePatch(userData, patch) + var mergedUser users.User + err = json.Unmarshal(mergedUserData, &mergedUser) + if err != nil { + c.JSON(http.StatusInternalServerError, ApiError{Message: err.Error()}) + return + } + + // CHanging email address is not allowed + if email != mergedUser.Email { + c.JSON(http.StatusBadRequest, ApiError{Message: "email parameter must match the model email address"}) + return + } + + if err := s.s.UpdateUser(mergedUser); err != nil { + c.JSON(http.StatusInternalServerError, ApiError{Message: err.Error()}) + return + } + + user = s.s.users.GetUserUnscoped(email) + if user == nil { + c.JSON(http.StatusNotFound, ApiError{Message: "user not found"}) + return + } + user.Password = "" // do not send password... + c.JSON(http.StatusOK, user) +} + +// DeleteUser godoc +// @Summary Deletes the specified user +// @Produce json +// @Param email path string true "User Email" +// @Success 204 "No content" +// @Failure 400 {object} ApiError +// @Failure 401 {object} ApiError +// @Failure 403 {object} ApiError +// @Failure 404 {object} ApiError +// @Failure 500 {object} ApiError +// @Router /user/{email} [delete] +// @Security ApiBasicAuth +func (s *ApiServer) DeleteUser(c *gin.Context) { + email := strings.ToLower(strings.TrimSpace(c.Param("email"))) + if email == "" { + c.JSON(http.StatusBadRequest, ApiError{Message: "email parameter must be specified"}) + return + } + + var user *users.User + if user = s.s.users.GetUserUnscoped(email); user == nil { + c.JSON(http.StatusNotFound, ApiError{Message: "user does not exist"}) + return + } + + if err := s.s.DeleteUser(*user); err != nil { + c.JSON(http.StatusInternalServerError, ApiError{Message: err.Error()}) + return + } + + c.Status(http.StatusNoContent) +} diff --git a/internal/server/docs/docs.go b/internal/server/docs/docs.go new file mode 100644 index 0000000..c0d1f25 --- /dev/null +++ b/internal/server/docs/docs.go @@ -0,0 +1,466 @@ +// GENERATED BY THE COMMAND ABOVE; DO NOT EDIT +// This file was generated by swaggo/swag + +package docs + +import ( + "bytes" + "encoding/json" + "strings" + + "github.com/alecthomas/template" + "github.com/swaggo/swag" +) + +var doc = `{ + "schemes": {{ marshal .Schemes }}, + "swagger": "2.0", + "info": { + "description": "{{.Description}}", + "title": "{{.Title}}", + "contact": {}, + "license": { + "name": "MIT", + "url": "https://github.com/h44z/wg-portal/blob/master/LICENSE.txt" + }, + "version": "{{.Version}}" + }, + "host": "{{.Host}}", + "basePath": "{{.BasePath}}", + "paths": { + "/user/{email}": { + "get": { + "security": [ + { + "ApiBasicAuth": [] + } + ], + "produces": [ + "application/json" + ], + "summary": "Retrieves user based on given Email", + "parameters": [ + { + "type": "string", + "description": "User Email", + "name": "email", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/users.User" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/server.ApiError" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/server.ApiError" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/server.ApiError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/server.ApiError" + } + } + } + }, + "put": { + "security": [ + { + "ApiBasicAuth": [] + } + ], + "produces": [ + "application/json" + ], + "summary": "Updates a user based on the given user model", + "parameters": [ + { + "type": "string", + "description": "User Email", + "name": "email", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/users.User" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/server.ApiError" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/server.ApiError" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/server.ApiError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/server.ApiError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/server.ApiError" + } + } + } + }, + "delete": { + "security": [ + { + "ApiBasicAuth": [] + } + ], + "produces": [ + "application/json" + ], + "summary": "Deletes the specified user", + "parameters": [ + { + "type": "string", + "description": "User Email", + "name": "email", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/server.ApiError" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/server.ApiError" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/server.ApiError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/server.ApiError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/server.ApiError" + } + } + } + }, + "patch": { + "security": [ + { + "ApiBasicAuth": [] + } + ], + "produces": [ + "application/json" + ], + "summary": "Updates a user based on the given partial user model", + "parameters": [ + { + "type": "string", + "description": "User Email", + "name": "email", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/users.User" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/server.ApiError" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/server.ApiError" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/server.ApiError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/server.ApiError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/server.ApiError" + } + } + } + } + }, + "/users": { + "get": { + "security": [ + { + "ApiBasicAuth": [] + } + ], + "produces": [ + "application/json" + ], + "summary": "Retrieves all users", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/users.User" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/server.ApiError" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/server.ApiError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/server.ApiError" + } + } + } + }, + "post": { + "security": [ + { + "ApiBasicAuth": [] + } + ], + "produces": [ + "application/json" + ], + "summary": "Creates a new user based on the given user model", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/users.User" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/server.ApiError" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/server.ApiError" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/server.ApiError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/server.ApiError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/server.ApiError" + } + } + } + } + } + }, + "definitions": { + "gorm.DeletedAt": { + "type": "object", + "properties": { + "time": { + "type": "string" + }, + "valid": { + "description": "Valid is true if Time is not NULL", + "type": "boolean" + } + } + }, + "server.ApiError": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + }, + "users.User": { + "type": "object", + "required": [ + "email", + "firstname", + "lastname" + ], + "properties": { + "createdAt": { + "description": "database internal fields", + "type": "string" + }, + "deletedAt": { + "$ref": "#/definitions/gorm.DeletedAt" + }, + "email": { + "description": "required fields", + "type": "string" + }, + "firstname": { + "description": "optional fields", + "type": "string" + }, + "isAdmin": { + "type": "boolean" + }, + "lastname": { + "type": "string" + }, + "password": { + "description": "optional, integrated password authentication", + "type": "string" + }, + "phone": { + "type": "string" + }, + "source": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + } + } + }, + "securityDefinitions": { + "ApiBasicAuth": { + "type": "basic" + } + } +}` + +type swaggerInfo struct { + Version string + Host string + BasePath string + Schemes []string + Title string + Description string +} + +// SwaggerInfo holds exported Swagger Info so clients can modify it +var SwaggerInfo = swaggerInfo{ + Version: "1.0", + Host: "", + BasePath: "/api/v1", + Schemes: []string{}, + Title: "WireGuard Portal API", + Description: "WireGuard Portal API for managing users and peers.", +} + +type s struct{} + +func (s *s) ReadDoc() string { + sInfo := SwaggerInfo + sInfo.Description = strings.Replace(sInfo.Description, "\n", "\\n", -1) + + t, err := template.New("swagger_info").Funcs(template.FuncMap{ + "marshal": func(v interface{}) string { + a, _ := json.Marshal(v) + return string(a) + }, + }).Parse(doc) + if err != nil { + return doc + } + + var tpl bytes.Buffer + if err := t.Execute(&tpl, sInfo); err != nil { + return doc + } + + return tpl.String() +} + +func init() { + swag.Register(swag.Name, &s{}) +} diff --git a/internal/server/routes.go b/internal/server/routes.go index 6eb47ac..a876fa2 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -2,9 +2,14 @@ package server import ( "net/http" + "strings" "github.com/gin-gonic/gin" wgportal "github.com/h44z/wg-portal" + "github.com/h44z/wg-portal/internal/authentication" + _ "github.com/h44z/wg-portal/internal/server/docs" // docs is generated by Swag CLI, you have to import it. + ginSwagger "github.com/swaggo/gin-swagger" + "github.com/swaggo/gin-swagger/swaggerFiles" ) func SetupRoutes(s *Server) { @@ -60,6 +65,23 @@ func SetupRoutes(s *Server) { user.GET("/status", s.GetPeerStatus) } +func SetupApiRoutes(s *Server) { + api := ApiServer{s: s} + + // Auth routes + apiV1 := s.server.Group("/api/v1") + apiV1.Use(s.RequireApiAuthentication("admin")) + apiV1.GET("/users", api.GetUsers) + apiV1.POST("/users", api.PostUser) + apiV1.GET("/user/:email", api.GetUser) + apiV1.PUT("/user/:email", api.PutUser) + apiV1.PATCH("/user/:email", api.PatchUser) + apiV1.DELETE("/user/:email", api.DeleteUser) + + // Swagger doc/ui + s.server.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) +} + func (s *Server) RequireAuthentication(scope string) gin.HandlerFunc { return func(c *gin.Context) { session := GetSessionData(c) @@ -78,7 +100,7 @@ func (s *Server) RequireAuthentication(scope string) gin.HandlerFunc { return } - // default case if some randome scope was set... + // default case if some random scope was set... if scope != "" && !session.IsAdmin { // Abort the request with the appropriate error code c.Abort() @@ -90,3 +112,67 @@ func (s *Server) RequireAuthentication(scope string) gin.HandlerFunc { c.Next() } } + +func (s *Server) RequireApiAuthentication(scope string) gin.HandlerFunc { + return func(c *gin.Context) { + username, password, hasAuth := c.Request.BasicAuth() + if !hasAuth { + c.Abort() + c.JSON(http.StatusUnauthorized, ApiError{Message: "unauthorized"}) + return + } + + // Validate form input + if strings.Trim(username, " ") == "" || strings.Trim(password, " ") == "" { + c.Abort() + c.JSON(http.StatusUnauthorized, ApiError{Message: "unauthorized"}) + return + } + + // Check user database for an matching entry + var loginProvider authentication.AuthProvider + user := s.users.GetUser(username) // retrieve active candidate user from db + if user == nil || user.Email == "" { + c.Abort() + c.JSON(http.StatusUnauthorized, ApiError{Message: "unauthorized"}) + return + } + + loginProvider = s.auth.GetProvider(string(user.Source)) + if loginProvider == nil { + c.Abort() + c.JSON(http.StatusUnauthorized, ApiError{Message: "unauthorized"}) + return + } + authEmail, err := loginProvider.Login(&authentication.AuthContext{ + Username: username, + Password: password, + }) + + // Test if authentication succeeded + if err != nil || authEmail == "" { + c.Abort() + c.JSON(http.StatusUnauthorized, ApiError{Message: "unauthorized"}) + return + } + + // Check admin scope + if scope == "admin" && !user.IsAdmin { + // Abort the request with the appropriate error code + c.Abort() + c.JSON(http.StatusForbidden, ApiError{Message: "unauthorized"}) + return + } + + // default case if some random scope was set... + if scope != "" && !user.IsAdmin { + // Abort the request with the appropriate error code + c.Abort() + c.JSON(http.StatusForbidden, ApiError{Message: "unauthorized"}) + return + } + + // Continue down the chain to handler etc + c.Next() + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 143bcd3..7577211 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -151,6 +151,7 @@ func (s *Server) Setup(ctx context.Context) error { // Setup all routes SetupRoutes(s) + SetupApiRoutes(s) // Setup user database (also needed for database authentication) s.users, err = users.NewManager(s.db)