WIP: RESTful API for WireGuard Portal, user endpoint (#11)

This commit is contained in:
Christoph Haas 2021-04-26 14:40:49 +02:00
parent b6d9814021
commit 35513ae994
7 changed files with 835 additions and 1 deletions

2
.gitignore vendored
View File

@ -32,3 +32,5 @@ ssh.key
.testCoverage.txt
wg_portal.db
go.sum
swagger.json
swagger.yaml

View File

@ -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 $@ $<

10
go.mod
View File

@ -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

265
internal/server/api.go Normal file
View File

@ -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)
}

View File

@ -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{})
}

View File

@ -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()
}
}

View File

@ -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)