oauth2 oidc, vuex store

This commit is contained in:
vx3r 2020-04-28 20:11:49 +09:00
parent f90124afbf
commit 125ddaef0f
36 changed files with 2050 additions and 847 deletions

35
.env
View File

@ -1,12 +1,41 @@
# IP address to listen to
SERVER=0.0.0.0
# port to bind
PORT=8080
# Gin framework release mode
GIN_MODE=release

# where to write all generated config files
WG_CONF_DIR=./wireguard
# WireGuard main config file name, generally <interface name>.conf
WG_INTERFACE_NAME=wg0.conf

# SMTP settings to send email to clients
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USERNAME=account@gmail.com
SMTP_PASSWORD="*************"
SMTP_FROM="Wg Gen Web <account@gmail.com>"
SMTP_PASSWORD=*************
SMTP_FROM=Wg Gen Web <account@gmail.com>

# example with gitlab, which is RFC implementation and no need any custom stuff
OAUTH2_PROVIDER_NAME=oauth2oidc
OAUTH2_PROVIDER=https://gitlab.com
OAUTH2_CLIENT_ID=
OAUTH2_CLIENT_SECRET=
OAUTH2_REDIRECT_URL=https://wg-gen-web-demo.127-0-0-1.fr

# example with google
OAUTH2_PROVIDER_NAME=google
OAUTH2_PROVIDER=
OAUTH2_CLIENT_ID=
OAUTH2_CLIENT_SECRET=
OAUTH2_REDIRECT_URL=

# example with github
OAUTH2_PROVIDER_NAME=github
OAUTH2_PROVIDER=
OAUTH2_CLIENT_ID=
OAUTH2_CLIENT_SECRET=
OAUTH2_REDIRECT_URL=

# set provider name to fake to disable auth, also the default
OAUTH2_PROVIDER_NAME=fake

View File

@ -2,244 +2,12 @@ package api

import (
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
"github.com/skip2/go-qrcode"
"gitlab.127-0-0-1.fr/vx3r/wg-gen-web/core"
"gitlab.127-0-0-1.fr/vx3r/wg-gen-web/model"
"gitlab.127-0-0-1.fr/vx3r/wg-gen-web/template"
"gitlab.127-0-0-1.fr/vx3r/wg-gen-web/version"
"net/http"
"gitlab.127-0-0-1.fr/vx3r/wg-gen-web/api/v1"
)

// ApplyRoutes applies router to gin Router
func ApplyRoutes(r *gin.Engine) {
client := r.Group("/api/v1.0/client")
func ApplyRoutes(r *gin.Engine, private bool) {
api := r.Group("/api")
{

client.POST("", createClient)
client.GET("/:id", readClient)
client.PATCH("/:id", updateClient)
client.DELETE("/:id", deleteClient)
client.GET("", readClients)
client.GET("/:id/config", configClient)
client.GET("/:id/email", emailClient)
}

server := r.Group("/api/v1.0/server")
{
server.GET("", readServer)
server.PATCH("", updateServer)
server.GET("/config", configServer)
server.GET("/version", versionStr)
apiv1.ApplyRoutes(api, private)
}
}

func createClient(c *gin.Context) {
var data model.Client

if err := c.ShouldBindJSON(&data); err != nil {
log.WithFields(log.Fields{
"err": err,
}).Error("failed to bind")
c.AbortWithStatus(http.StatusUnprocessableEntity)
return
}

client, err := core.CreateClient(&data)
if err != nil {
log.WithFields(log.Fields{
"err": err,
}).Error("failed to create client")
c.AbortWithStatus(http.StatusInternalServerError)
return
}

c.JSON(http.StatusOK, client)
}

func readClient(c *gin.Context) {
id := c.Param("id")

client, err := core.ReadClient(id)
if err != nil {
log.WithFields(log.Fields{
"err": err,
}).Error("failed to read client")
c.AbortWithStatus(http.StatusInternalServerError)
return
}

c.JSON(http.StatusOK, client)
}

func updateClient(c *gin.Context) {
var data model.Client
id := c.Param("id")

if err := c.ShouldBindJSON(&data); err != nil {
log.WithFields(log.Fields{
"err": err,
}).Error("failed to bind")
c.AbortWithStatus(http.StatusUnprocessableEntity)
return
}

client, err := core.UpdateClient(id, &data)
if err != nil {
log.WithFields(log.Fields{
"err": err,
}).Error("failed to update client")
c.AbortWithStatus(http.StatusInternalServerError)
return
}

c.JSON(http.StatusOK, client)
}

func deleteClient(c *gin.Context) {
id := c.Param("id")

err := core.DeleteClient(id)
if err != nil {
log.WithFields(log.Fields{
"err": err,
}).Error("failed to remove client")
c.AbortWithStatus(http.StatusInternalServerError)
return
}

c.JSON(http.StatusOK, gin.H{})
}

func readClients(c *gin.Context) {
clients, err := core.ReadClients()
if err != nil {
log.WithFields(log.Fields{
"err": err,
}).Error("failed to list clients")
c.AbortWithStatus(http.StatusInternalServerError)
return
}

c.JSON(http.StatusOK, clients)
}

func configClient(c *gin.Context) {
configData, err := core.ReadClientConfig(c.Param("id"))
if err != nil {
log.WithFields(log.Fields{
"err": err,
}).Error("failed to read client config")
c.AbortWithStatus(http.StatusInternalServerError)
return
}

formatQr := c.DefaultQuery("qrcode", "false")
if formatQr == "false" {
// return config as txt file
c.Header("Content-Disposition", "attachment; filename=wg0.conf")
c.Data(http.StatusOK, "application/config", configData)
return
}
// return config as png qrcode
png, err := qrcode.Encode(string(configData), qrcode.Medium, 250)
if err != nil {
log.WithFields(log.Fields{
"err": err,
}).Error("failed to create qrcode")
c.AbortWithStatus(http.StatusInternalServerError)
return
}
c.Data(http.StatusOK, "image/png", png)
return
}

func emailClient(c *gin.Context) {
id := c.Param("id")

err := core.EmailClient(id)
if err != nil {
log.WithFields(log.Fields{
"err": err,
}).Error("failed to send email to client")
c.AbortWithStatus(http.StatusInternalServerError)
return
}

c.JSON(http.StatusOK, gin.H{})
}

func readServer(c *gin.Context) {
client, err := core.ReadServer()
if err != nil {
log.WithFields(log.Fields{
"err": err,
}).Error("failed to read client")
c.AbortWithStatus(http.StatusInternalServerError)
return
}

c.JSON(http.StatusOK, client)
}

func updateServer(c *gin.Context) {
var data model.Server

if err := c.ShouldBindJSON(&data); err != nil {
log.WithFields(log.Fields{
"err": err,
}).Error("failed to bind")
c.AbortWithStatus(http.StatusUnprocessableEntity)
return
}

client, err := core.UpdateServer(&data)
if err != nil {
log.WithFields(log.Fields{
"err": err,
}).Error("failed to update client")
c.AbortWithStatus(http.StatusInternalServerError)
return
}

c.JSON(http.StatusOK, client)
}

func configServer(c *gin.Context) {
clients, err := core.ReadClients()
if err != nil {
log.WithFields(log.Fields{
"err": err,
}).Error("failed to read clients")
c.AbortWithStatus(http.StatusUnprocessableEntity)
return
}

server, err := core.ReadServer()
if err != nil {
log.WithFields(log.Fields{
"err": err,
}).Error("failed to read server")
c.AbortWithStatus(http.StatusUnprocessableEntity)
return
}

configData, err := template.DumpServerWg(clients, server)
if err != nil {
log.WithFields(log.Fields{
"err": err,
}).Error("failed to dump wg config")
c.AbortWithStatus(http.StatusUnprocessableEntity)
return
}

// return config as txt file
c.Header("Content-Disposition", "attachment; filename=wg0.conf")
c.Data(http.StatusOK, "application/config", configData)
}

func versionStr(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"version": version.Version,
})
}

134
api/v1/auth/auth.go Normal file
View File

@ -0,0 +1,134 @@
package auth

import (
"github.com/gin-gonic/gin"
"github.com/patrickmn/go-cache"
log "github.com/sirupsen/logrus"
"gitlab.127-0-0-1.fr/vx3r/wg-gen-web/auth"
"gitlab.127-0-0-1.fr/vx3r/wg-gen-web/model"
"gitlab.127-0-0-1.fr/vx3r/wg-gen-web/util"
"golang.org/x/oauth2"
"net/http"
"time"
)

// ApplyRoutes applies router to gin Router
func ApplyRoutes(r *gin.RouterGroup) {
g := r.Group("/auth")
{
g.GET("/oauth2_url", oauth2_url)
g.POST("/oauth2_exchange", oauth2_exchange)
g.GET("/user", user)
g.GET("/logout", logout)
}
}

/*
* generate redirect url to get OAuth2 code or let client know that OAuth2 is disabled
*/
func oauth2_url(c *gin.Context) {
cacheDb := c.MustGet("cache").(*cache.Cache)

state, err := util.GenerateRandomString(32)
if err != nil {
log.WithFields(log.Fields{
"err": err,
}).Error("failed to generate state random string")
c.AbortWithStatus(http.StatusInternalServerError)
}

clientId, err := util.GenerateRandomString(32)
if err != nil {
log.WithFields(log.Fields{
"err": err,
}).Error("failed to generate state random string")
c.AbortWithStatus(http.StatusInternalServerError)
}
// save clientId and state so we can retrieve for verification
cacheDb.Set(clientId, state, 5*time.Minute)

oauth2Client := c.MustGet("oauth2Client").(auth.Auth)

data := &model.Auth{
Oauth2: true,
ClientId: clientId,
State: state,
CodeUrl: oauth2Client.CodeUrl(state),
}

c.JSON(http.StatusOK, data)
}

/*
* exchange code and get user infos, if OAuth2 is disable just send fake data
*/
func oauth2_exchange(c *gin.Context) {
var loginVals model.Auth
if err := c.ShouldBind(&loginVals); err != nil {
log.WithFields(log.Fields{
"err": err,
}).Error("code and state fields are missing")
c.AbortWithStatus(http.StatusUnprocessableEntity)
return
}

cacheDb := c.MustGet("cache").(*cache.Cache)
savedState, exists := cacheDb.Get(loginVals.ClientId)

if !exists || savedState != loginVals.State {
log.WithFields(log.Fields{
"state": loginVals.State,
"savedState": savedState,
}).Error("saved state and client provided state mismatch")
c.AbortWithStatus(http.StatusBadRequest)
return
}
oauth2Client := c.MustGet("oauth2Client").(auth.Auth)

oauth2Token, err := oauth2Client.Exchange(loginVals.Code)
if err != nil {
log.WithFields(log.Fields{
"err": err,
}).Error("failed to exchange code for token")
c.AbortWithStatus(http.StatusBadRequest)
return
}

cacheDb.Delete(loginVals.ClientId)
cacheDb.Set(oauth2Token.AccessToken, oauth2Token, cache.DefaultExpiration)

c.JSON(http.StatusOK, oauth2Token.AccessToken)
}

func logout(c *gin.Context) {
cacheDb := c.MustGet("cache").(*cache.Cache)
cacheDb.Delete(c.Request.Header.Get(util.AuthTokenHeaderName))
c.JSON(http.StatusOK, gin.H{})
}

func user(c *gin.Context) {
cacheDb := c.MustGet("cache").(*cache.Cache)
oauth2Token, exists := cacheDb.Get(c.Request.Header.Get(util.AuthTokenHeaderName))

if exists && oauth2Token.(*oauth2.Token).AccessToken == c.Request.Header.Get(util.AuthTokenHeaderName) {
oauth2Client := c.MustGet("oauth2Client").(auth.Auth)
user, err := oauth2Client.UserInfo(oauth2Token.(*oauth2.Token))
if err != nil {
log.WithFields(log.Fields{
"err": err,
}).Error("failed to get user from oauth2 AccessToken")
c.AbortWithStatus(http.StatusBadRequest)
return
}

c.JSON(http.StatusOK, user)
return
}

log.WithFields(log.Fields{
"exists": exists,
util.AuthTokenHeaderName: c.Request.Header.Get(util.AuthTokenHeaderName),
}).Error("oauth2 AccessToken is not recognized")

c.AbortWithStatus(http.StatusUnauthorized)
}

190
api/v1/client/client.go Normal file
View File

@ -0,0 +1,190 @@
package client

import (
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
"github.com/skip2/go-qrcode"
"gitlab.127-0-0-1.fr/vx3r/wg-gen-web/auth"
"gitlab.127-0-0-1.fr/vx3r/wg-gen-web/core"
"gitlab.127-0-0-1.fr/vx3r/wg-gen-web/model"
"golang.org/x/oauth2"
"net/http"
)

// ApplyRoutes applies router to gin Router
func ApplyRoutes(r *gin.RouterGroup) {
g := r.Group("/client")
{

g.POST("", createClient)
g.GET("/:id", readClient)
g.PATCH("/:id", updateClient)
g.DELETE("/:id", deleteClient)
g.GET("", readClients)
g.GET("/:id/config", configClient)
g.GET("/:id/email", emailClient)
}
}

func createClient(c *gin.Context) {
var data model.Client

if err := c.ShouldBindJSON(&data); err != nil {
log.WithFields(log.Fields{
"err": err,
}).Error("failed to bind")
c.AbortWithStatus(http.StatusUnprocessableEntity)
return
}

// get creation user from token and add to client infos
oauth2Token := c.MustGet("oauth2Token").(*oauth2.Token)
oauth2Client := c.MustGet("oauth2Client").(auth.Auth)
user, err := oauth2Client.UserInfo(oauth2Token)
if err != nil {
log.WithFields(log.Fields{
"oauth2Token": oauth2Token,
"err": err,
}).Error("failed to get user with oauth token")
c.AbortWithStatus(http.StatusInternalServerError)
return
}
data.CreatedBy = user.Name

client, err := core.CreateClient(&data)
if err != nil {
log.WithFields(log.Fields{
"err": err,
}).Error("failed to create client")
c.AbortWithStatus(http.StatusInternalServerError)
return
}

c.JSON(http.StatusOK, client)
}

func readClient(c *gin.Context) {
id := c.Param("id")

client, err := core.ReadClient(id)
if err != nil {
log.WithFields(log.Fields{
"err": err,
}).Error("failed to read client")
c.AbortWithStatus(http.StatusInternalServerError)
return
}

c.JSON(http.StatusOK, client)
}

func updateClient(c *gin.Context) {
var data model.Client
id := c.Param("id")

if err := c.ShouldBindJSON(&data); err != nil {
log.WithFields(log.Fields{
"err": err,
}).Error("failed to bind")
c.AbortWithStatus(http.StatusUnprocessableEntity)
return
}

// get update user from token and add to client infos
oauth2Token := c.MustGet("oauth2Token").(*oauth2.Token)
oauth2Client := c.MustGet("oauth2Client").(auth.Auth)
user, err := oauth2Client.UserInfo(oauth2Token)
if err != nil {
log.WithFields(log.Fields{
"oauth2Token": oauth2Token,
"err": err,
}).Error("failed to get user with oauth token")
c.AbortWithStatus(http.StatusInternalServerError)
return
}
data.UpdatedBy = user.Name

client, err := core.UpdateClient(id, &data)
if err != nil {
log.WithFields(log.Fields{
"err": err,
}).Error("failed to update client")
c.AbortWithStatus(http.StatusInternalServerError)
return
}

c.JSON(http.StatusOK, client)
}

func deleteClient(c *gin.Context) {
id := c.Param("id")

err := core.DeleteClient(id)
if err != nil {
log.WithFields(log.Fields{
"err": err,
}).Error("failed to remove client")
c.AbortWithStatus(http.StatusInternalServerError)
return
}

c.JSON(http.StatusOK, gin.H{})
}

func readClients(c *gin.Context) {
clients, err := core.ReadClients()
if err != nil {
log.WithFields(log.Fields{
"err": err,
}).Error("failed to list clients")
c.AbortWithStatus(http.StatusInternalServerError)
return
}

c.JSON(http.StatusOK, clients)
}

func configClient(c *gin.Context) {
configData, err := core.ReadClientConfig(c.Param("id"))
if err != nil {
log.WithFields(log.Fields{
"err": err,
}).Error("failed to read client config")
c.AbortWithStatus(http.StatusInternalServerError)
return
}

formatQr := c.DefaultQuery("qrcode", "false")
if formatQr == "false" {
// return config as txt file
c.Header("Content-Disposition", "attachment; filename=wg0.conf")
c.Data(http.StatusOK, "application/config", configData)
return
}
// return config as png qrcode
png, err := qrcode.Encode(string(configData), qrcode.Medium, 250)
if err != nil {
log.WithFields(log.Fields{
"err": err,
}).Error("failed to create qrcode")
c.AbortWithStatus(http.StatusInternalServerError)
return
}
c.Data(http.StatusOK, "image/png", png)
return
}

func emailClient(c *gin.Context) {
id := c.Param("id")

err := core.EmailClient(id)
if err != nil {
log.WithFields(log.Fields{
"err": err,
}).Error("failed to send email to client")
c.AbortWithStatus(http.StatusInternalServerError)
return
}

c.JSON(http.StatusOK, gin.H{})
}

113
api/v1/server/server.go Normal file
View File

@ -0,0 +1,113 @@
package server

import (
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
"gitlab.127-0-0-1.fr/vx3r/wg-gen-web/auth"
"gitlab.127-0-0-1.fr/vx3r/wg-gen-web/core"
"gitlab.127-0-0-1.fr/vx3r/wg-gen-web/model"
"gitlab.127-0-0-1.fr/vx3r/wg-gen-web/template"
"gitlab.127-0-0-1.fr/vx3r/wg-gen-web/version"
"golang.org/x/oauth2"
"net/http"
)

// ApplyRoutes applies router to gin Router
func ApplyRoutes(r *gin.RouterGroup) {
g := r.Group("/server")
{
g.GET("", readServer)
g.PATCH("", updateServer)
g.GET("/config", configServer)
g.GET("/version", versionStr)
}
}

func readServer(c *gin.Context) {
client, err := core.ReadServer()
if err != nil {
log.WithFields(log.Fields{
"err": err,
}).Error("failed to read client")
c.AbortWithStatus(http.StatusInternalServerError)
return
}

c.JSON(http.StatusOK, client)
}

func updateServer(c *gin.Context) {
var data model.Server

if err := c.ShouldBindJSON(&data); err != nil {
log.WithFields(log.Fields{
"err": err,
}).Error("failed to bind")
c.AbortWithStatus(http.StatusUnprocessableEntity)
return
}

// get update user from token and add to server infos
oauth2Token := c.MustGet("oauth2Token").(*oauth2.Token)
oauth2Client := c.MustGet("oauth2Client").(auth.Auth)
user, err := oauth2Client.UserInfo(oauth2Token)
if err != nil {
log.WithFields(log.Fields{
"oauth2Token": oauth2Token,
"err": err,
}).Error("failed to get user with oauth token")
c.AbortWithStatus(http.StatusInternalServerError)
return
}
data.UpdatedBy = user.Name

server, err := core.UpdateServer(&data)
if err != nil {
log.WithFields(log.Fields{
"err": err,
}).Error("failed to update client")
c.AbortWithStatus(http.StatusInternalServerError)
return
}

c.JSON(http.StatusOK, server)
}

func configServer(c *gin.Context) {
clients, err := core.ReadClients()
if err != nil {
log.WithFields(log.Fields{
"err": err,
}).Error("failed to read clients")
c.AbortWithStatus(http.StatusUnprocessableEntity)
return
}

server, err := core.ReadServer()
if err != nil {
log.WithFields(log.Fields{
"err": err,
}).Error("failed to read server")
c.AbortWithStatus(http.StatusUnprocessableEntity)
return
}

configData, err := template.DumpServerWg(clients, server)
if err != nil {
log.WithFields(log.Fields{
"err": err,
}).Error("failed to dump wg config")
c.AbortWithStatus(http.StatusUnprocessableEntity)
return
}

// return config as txt file
c.Header("Content-Disposition", "attachment; filename=wg0.conf")
c.Data(http.StatusOK, "application/config", configData)
}

func versionStr(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"version": version.Version,
})
}

21
api/v1/v1.go Normal file
View File

@ -0,0 +1,21 @@
package apiv1

import (
"github.com/gin-gonic/gin"
"gitlab.127-0-0-1.fr/vx3r/wg-gen-web/api/v1/auth"
"gitlab.127-0-0-1.fr/vx3r/wg-gen-web/api/v1/client"
"gitlab.127-0-0-1.fr/vx3r/wg-gen-web/api/v1/server"
)

func ApplyRoutes(r *gin.RouterGroup, private bool) {
v1 := r.Group("/v1.0")
{
if private {
client.ApplyRoutes(v1)
server.ApplyRoutes(v1)
} else {
auth.ApplyRoutes(v1)

}
}
}

13
auth/auth.go Normal file
View File

@ -0,0 +1,13 @@
package auth

import (
"gitlab.127-0-0-1.fr/vx3r/wg-gen-web/model"
"golang.org/x/oauth2"
)

type Auth interface {
Setup() error
CodeUrl(state string) string
Exchange(code string) (*oauth2.Token, error)
UserInfo(oauth2Token *oauth2.Token) (*model.User, error)
}

47
auth/fake/fake.go Normal file
View File

@ -0,0 +1,47 @@
package fake

import (
"gitlab.127-0-0-1.fr/vx3r/wg-gen-web/model"
"gitlab.127-0-0-1.fr/vx3r/wg-gen-web/util"
"golang.org/x/oauth2"
"time"
)

type Fake struct{}

// Setup validate provider
func (o *Fake) Setup() error {
return nil
}

// CodeUrl get url to redirect client for auth
func (o *Fake) CodeUrl(state string) string {
return "_magic_string_fake_auth_no_redirect_"
}

// Exchange exchange code for Oauth2 token
func (o *Fake) Exchange(code string) (*oauth2.Token, error) {
rand, err := util.GenerateRandomString(32)
if err != nil {
return nil, err
}

return &oauth2.Token{
AccessToken: rand,
TokenType: "",
RefreshToken: "",
Expiry: time.Time{},
}, nil
}

// UserInfo get token user
func (o *Fake) UserInfo(oauth2Token *oauth2.Token) (*model.User, error) {
return &model.User{
Sub: "unknown",
Name: "Unknown",
Email: "unknown",
Profile: "unknown",
Issuer: "unknown",
IssuedAt: time.Time{},
}, nil
}

1
auth/github/github.go Normal file
View File

@ -0,0 +1 @@
package github

1
auth/google/goolge.go Normal file
View File

@ -0,0 +1 @@
package google

View File

@ -0,0 +1,108 @@
package oauth2oidc

import (
"context"
"fmt"
"github.com/coreos/go-oidc"
log "github.com/sirupsen/logrus"
"gitlab.127-0-0-1.fr/vx3r/wg-gen-web/model"
"golang.org/x/oauth2"
"os"
)

type Oauth2idc struct{}

var (
oauth2Config *oauth2.Config
oidcProvider *oidc.Provider
oidcIDTokenVerifier *oidc.IDTokenVerifier
)

// Setup validate provider
func (o *Oauth2idc) Setup() error {
var err error

oidcProvider, err = oidc.NewProvider(context.TODO(), os.Getenv("OAUTH2_PROVIDER"))
if err != nil {
return err
}

oidcIDTokenVerifier = oidcProvider.Verifier(&oidc.Config{
ClientID: os.Getenv("OAUTH2_CLIENT_ID"),
})

oauth2Config = &oauth2.Config{
ClientID: os.Getenv("OAUTH2_CLIENT_ID"),
ClientSecret: os.Getenv("OAUTH2_CLIENT_SECRET"),
RedirectURL: os.Getenv("OAUTH2_REDIRECT_URL"),
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
Endpoint: oidcProvider.Endpoint(),
}

return nil
}

// CodeUrl get url to redirect client for auth
func (o *Oauth2idc) CodeUrl(state string) string {
return oauth2Config.AuthCodeURL(state)
}

// Exchange exchange code for Oauth2 token
func (o *Oauth2idc) Exchange(code string) (*oauth2.Token, error) {
oauth2Token, err := oauth2Config.Exchange(context.TODO(), code)
if err != nil {
return nil, err
}

return oauth2Token, nil
}

// UserInfo get token user
func (o *Oauth2idc) UserInfo(oauth2Token *oauth2.Token) (*model.User, error) {
rawIDToken, ok := oauth2Token.Extra("id_token").(string)
if !ok {
return nil, fmt.Errorf("no id_token field in oauth2 token")
}

iDToken, err := oidcIDTokenVerifier.Verify(context.TODO(), rawIDToken)
if err != nil {
return nil, err
}

userInfo, err := oidcProvider.UserInfo(context.TODO(), oauth2.StaticTokenSource(oauth2Token))
if err != nil {
return nil, err
}

type UserInfo struct {
Subject string `json:"sub"`
Profile string `json:"profile"`
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`

claims []byte
}

// ID Token payload is just JSON
var claims map[string]interface{}
if err := userInfo.Claims(&claims); err != nil {
return nil, fmt.Errorf("failed to get id token claims: %s", err)
}

// get some infos about user
user := &model.User{}
user.Sub = userInfo.Subject
user.Email = userInfo.Email
user.Profile = userInfo.Profile

if v, found := claims["name"]; found && v != nil {
user.Name = v.(string)
} else {
log.Error("name not found in user info claims")
}

user.Issuer = iDToken.Issuer
user.IssuedAt = iDToken.IssuedAt

return user, nil
}

View File

@ -7,13 +7,24 @@ import (
"github.com/gin-contrib/static"
"github.com/gin-gonic/gin"
"github.com/joho/godotenv"
"github.com/patrickmn/go-cache"
log "github.com/sirupsen/logrus"
"gitlab.127-0-0-1.fr/vx3r/wg-gen-web/api"
"gitlab.127-0-0-1.fr/vx3r/wg-gen-web/auth"
"gitlab.127-0-0-1.fr/vx3r/wg-gen-web/auth/fake"
"gitlab.127-0-0-1.fr/vx3r/wg-gen-web/auth/oauth2oidc"
"gitlab.127-0-0-1.fr/vx3r/wg-gen-web/core"
"gitlab.127-0-0-1.fr/vx3r/wg-gen-web/util"
"gitlab.127-0-0-1.fr/vx3r/wg-gen-web/version"
"golang.org/x/oauth2"
"net/http"
"os"
"path/filepath"
"time"
)

var (
cacheDb = cache.New(60*time.Minute, 10*time.Minute)
)

func init() {
@ -80,21 +91,88 @@ func main() {
// cors middleware
config := cors.DefaultConfig()
config.AllowAllOrigins = true
config.AddAllowHeaders("Authorization")
app.Use(cors.New(config))

// protection middleware
app.Use(helmet.Default())

// no route redirect to frontend app
app.NoRoute(func(c *gin.Context) {
c.Redirect(301, "/index.html")
// add cache storage to gin app
app.Use(func(ctx *gin.Context) {
ctx.Set("cache", cacheDb)
ctx.Next()
})

// serve static files
app.Use(static.Serve("/", static.LocalFile("./ui/dist", false)))

// apply api router
api.ApplyRoutes(app)
// setup Oauth2 client if enabled
var oauth2Client auth.Auth

switch os.Getenv("OAUTH2_PROVIDER_NAME") {
case "fake":
log.Warn("Oauth is set to fake, no actual authentication will be performed")
oauth2Client = &fake.Fake{}
err = oauth2Client.Setup()
if err != nil {
log.WithFields(log.Fields{
"OAUTH2_PROVIDER_NAME": "oauth2oidc",
"err": err,
}).Fatal("failed to setup Oauth2")
}
case "oauth2oidc":
log.Warn("Oauth is set to oauth2oidc, must be RFC implementation on server side")
oauth2Client = &oauth2oidc.Oauth2idc{}
err = oauth2Client.Setup()
if err != nil {
log.WithFields(log.Fields{
"OAUTH2_PROVIDER_NAME": "oauth2oidc",
"err": err,
}).Fatal("failed to setup Oauth2")
}
default:
log.WithFields(log.Fields{
"OAUTH2_PROVIDER_NAME": os.Getenv("OAUTH2_PROVIDER_NAME"),
}).Fatal("auth provider name unknown")
}

if os.Getenv("OAUTH2_ENABLE") == "true" {
oauth2Client = &oauth2oidc.Oauth2idc{}
err = oauth2Client.Setup()
if err != nil {
log.WithFields(log.Fields{
"err": err,
}).Fatal("failed to setup Oauth2")
}
}
app.Use(func(ctx *gin.Context) {
ctx.Set("oauth2Client", oauth2Client)
ctx.Next()
})

// apply api routes public
api.ApplyRoutes(app, false)

// simple middleware to check auth
app.Use(func(c *gin.Context) {
cacheDb := c.MustGet("cache").(*cache.Cache)

token := c.Request.Header.Get(util.AuthTokenHeaderName)

oauth2Token, exists := cacheDb.Get(token)
if exists && oauth2Token.(*oauth2.Token).AccessToken == token {
// will be accessible in auth endpoints
c.Set("oauth2Token", oauth2Token)
c.Next()
return
}

c.AbortWithStatus(http.StatusUnauthorized)
return
})

// apply api router private
api.ApplyRoutes(app, true)

err = app.Run(fmt.Sprintf("%s:%s", os.Getenv("SERVER"), os.Getenv("PORT")))
if err != nil {

11
go.mod
View File

@ -3,15 +3,26 @@ module gitlab.127-0-0-1.fr/vx3r/wg-gen-web
go 1.14

require (
github.com/appleboy/gin-jwt/v2 v2.6.3 // indirect
github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff // indirect
github.com/coreos/go-oidc v2.2.1+incompatible
github.com/danielkov/gin-helmet v0.0.0-20171108135313-1387e224435e
github.com/gin-contrib/cors v1.3.1
github.com/gin-contrib/sessions v0.0.3
github.com/gin-contrib/static v0.0.0-20191128031702-f81c604d8ac2
github.com/gin-gonic/contrib v0.0.0-20191209060500-d6e26eeaa607 // indirect
github.com/gin-gonic/gin v1.6.2
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b // indirect
github.com/gorilla/sessions v1.2.0 // indirect
github.com/joho/godotenv v1.3.0
github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/pquerna/cachecontrol v0.0.0-20180517163645-1555304b9b35 // indirect
github.com/satori/go.uuid v1.2.0
github.com/sirupsen/logrus v1.5.0
github.com/skip2/go-qrcode v0.0.0-20191027152451-9434209cb086
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20200324154536-ceff61240acf
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
gopkg.in/square/go-jose.v2 v2.5.0 // indirect
)

9
model/auth.go Normal file
View File

@ -0,0 +1,9 @@
package model

type Auth struct {
Oauth2 bool `json:"oauth2"`
ClientId string `json:"clientId"`
Code string `json:"code"`
State string `json:"state"`
CodeUrl string `json:"codeUrl"`
}

View File

@ -18,6 +18,8 @@ type Client struct {
Address []string `json:"address"`
PrivateKey string `json:"privateKey"`
PublicKey string `json:"publicKey"`
CreatedBy string `json:"createdBy"`
UpdatedBy string `json:"updatedBy"`
Created time.Time `json:"created"`
Updated time.Time `json:"updated"`
}

View File

@ -21,6 +21,7 @@ type Server struct {
PostUp string `json:"postUp"`
PreDown string `json:"preDown"`
PostDown string `json:"postDown"`
UpdatedBy string `json:"updatedBy"`
Created time.Time `json:"created"`
Updated time.Time `json:"updated"`
}

12
model/user.go Normal file
View File

@ -0,0 +1,12 @@
package model

import "time"

type User struct {
Sub string `json:"sub"`
Name string `json:"name"`
Email string `json:"email"`
Profile string `json:"profile"`
Issuer string `json:"issuer"`
IssuedAt time.Time `json:"issuedAt"`
}

602
ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -11,9 +11,11 @@
"is-cidr": "^3.1.0",
"moment": "^2.24.0",
"vue": "^2.6.10",
"vue-axios": "^2.1.5",
"vue-moment": "^4.1.0",
"vue-router": "^3.1.6",
"vuetify": "^2.2.22"
"vuetify": "^2.2.22",
"vuex": "^3.2.0"
},
"devDependencies": {
"@vue/cli-plugin-router": "^4.3.1",

View File

@ -1,69 +1,36 @@
<template>
<v-app id="inspire">
<Notification v-bind:notification="notification"/>
<div v-if="this.isAuthenticated">
<Header/>

<v-app-bar app>
<img class="mr-3" :src="require('./assets/logo.png')" height="50" alt="Wg Gen Web"/>
<v-toolbar-title to="/">Wg Gen Web</v-toolbar-title>

<v-spacer />

<v-toolbar-items>
<v-btn to="/clients">
Clients
<v-icon right dark>mdi-account-network-outline</v-icon>
</v-btn>
<v-btn to="/server">
Server
<v-icon right dark>mdi-vpn</v-icon>
</v-btn>
</v-toolbar-items>

</v-app-bar>

<v-content>
<v-container>
<router-view />
</v-container>
<Notification v-bind:notification="notification"/>
</v-content>

<v-footer app>
<v-row justify="start" no-gutters>
<v-col cols="12" lg="6" md="12" sm="12">
<div :align="$vuetify.breakpoint.smAndDown ? 'center' : 'left'">
<small>Copyright &copy; {{ new Date().getFullYear() }}, Wg Gen Web.</small>
<small>This work is licensed under a <a class="pr-1 pl-1" href="http://www.wtfpl.net/" target="_blank">WTFPL License.</a></small>
</div>
</v-col>
</v-row>
<v-row justify="end" no-gutters>
<v-col cols="12" lg="6" md="12" sm="12">
<div :align="$vuetify.breakpoint.smAndDown ? 'center' : 'right'">
<small>Created with</small>
<v-icon class="pr-1 pl-1">mdi-heart</v-icon><span>by</span><a class="pr-2 pl-1" href="mailto:vx3r@127-0-0-1.fr">vx3r</a>
<a :href="'https://github.com/vx3r/wg-gen-web/commit/' + version"><kbd>Version: {{ version.substring(0,7) }}</kbd></a>
</div>
</v-col>
</v-row>
</v-footer>
<v-content>
<v-container>
<router-view />
</v-container>
</v-content>

<Footer/>
</div>
</v-app>
</template>

<script>
import {ApiService} from "./services/ApiService";
import Notification from './components/Notification'
import Header from "./components/Header";
import Footer from "./components/Footer";
import {mapActions, mapGetters} from "vuex";

export default {
name: 'App',

components: {
Footer,
Header,
Notification
},

data: () => ({
api: null,
version:'N/A',
notification: {
show: false,
color: '',
@ -71,23 +38,69 @@
},
}),

mounted() {
this.api = new ApiService();
this.getVersion()
computed:{
...mapGetters({
isAuthenticated: 'auth/isAuthenticated',
authStatus: 'auth/authStatus',
authRedirectUrl: 'auth/authRedirectUrl',
authError: 'auth/error',
clientError: 'client/error',
serverError: 'server/error',
})
},

created () {
this.$vuetify.theme.dark = true
},

methods: {
getVersion() {
this.api.get('/server/version').then((res) => {
this.version = res.version;
}).catch((e) => {
this.notify('error', e.response.status + ' ' + e.response.statusText);
});
mounted() {
if (this.$route.query.code && this.$route.query.state) {
this.oauth2_exchange({
code: this.$route.query.code,
state: this.$route.query.state
})
} else {
this.oauth2_url()
}
},

watch: {
authError(newValue, oldValue) {
console.log(newValue)
this.notify('error', newValue);
},

clientError(newValue, oldValue) {
console.log(newValue)
this.notify('error', newValue);
},

serverError(newValue, oldValue) {
console.log(newValue)
this.notify('error', newValue);
},

isAuthenticated(newValue, oldValue) {
console.log(`Updating isAuthenticated from ${oldValue} to ${newValue}`);
if (newValue === true) {
this.$router.push('/clients')
}
},

authStatus(newValue, oldValue) {
console.log(`Updating authStatus from ${oldValue} to ${newValue}`);
if (newValue === 'redirect') {
window.location.replace(this.authRedirectUrl)
}
},
},

methods: {
...mapActions('auth', {
oauth2_exchange: 'oauth2_exchange',
oauth2_url: 'oauth2_url',
}),

notify(color, msg) {
this.notification.show = true;
this.notification.color = color;

View File

@ -9,7 +9,7 @@
</v-list-item-content>
<v-btn
color="success"
@click="startAddClient"
@click="startCreate"
>
Add new client
<v-icon right dark>mdi-account-multiple-plus-outline</v-icon>
@ -31,15 +31,15 @@
<v-list-item-content>
<v-list-item-title class="headline">{{ client.name }}</v-list-item-title>
<v-list-item-subtitle>{{ client.email }}</v-list-item-subtitle>
<v-list-item-subtitle>Created: {{ client.created | formatDate }}</v-list-item-subtitle>
<v-list-item-subtitle>Updated: {{ client.updated | formatDate }}</v-list-item-subtitle>
<v-list-item-subtitle>Created: {{ client.created | formatDate }} by {{ client.createdBy }}</v-list-item-subtitle>
<v-list-item-subtitle>Updated: {{ client.updated | formatDate }} by {{ client.updatedBy }}</v-list-item-subtitle>
</v-list-item-content>

<v-list-item-avatar
tile
size="150"
>
<v-img :src="`${apiBaseUrl}/client/${client.id}/config?qrcode=true`"/>
<v-img :src="'data:image/png;base64, ' + getClientQrcode(client.id)"/>
</v-list-item-avatar>
</v-list-item>

@ -55,61 +55,61 @@
</v-chip>
</v-card-text>
<v-card-actions>
<v-tooltip bottom>
<template v-slot:activator="{ on }">
<v-btn
text
:href="`${apiBaseUrl}/client/${client.id}/config?qrcode=false`"
v-on="on"
>
<span class="d-none d-lg-flex">Download</span>
<v-icon right dark>mdi-cloud-download-outline</v-icon>
</v-btn>
</template>
<span>Download</span>
</v-tooltip>
<v-tooltip bottom>
<template v-slot:activator="{ on }">
<v-btn
text
v-on:click="forceFileDownload(client)"
v-on="on"
>
<span class="d-none d-lg-flex">Download</span>
<v-icon right dark>mdi-cloud-download-outline</v-icon>
</v-btn>
</template>
<span>Download</span>
</v-tooltip>

<v-tooltip bottom>
<template v-slot:activator="{ on }">
<v-btn
text
@click.stop="startUpdateClient(client)"
v-on="on"
>
<span class="d-none d-lg-flex">Edit</span>
<v-icon right dark>mdi-square-edit-outline</v-icon>
</v-btn>
</template>
<span>Edit</span>
</v-tooltip>
<v-tooltip bottom>
<template v-slot:activator="{ on }">
<v-btn
text
@click.stop="startUpdate(client)"
v-on="on"
>
<span class="d-none d-lg-flex">Edit</span>
<v-icon right dark>mdi-square-edit-outline</v-icon>
</v-btn>
</template>
<span>Edit</span>
</v-tooltip>

<v-tooltip bottom>
<template v-slot:activator="{ on }">
<v-btn
text
@click="deleteClient(client)"
v-on="on"
>
<span class="d-none d-lg-flex">Delete</span>
<v-icon right dark>mdi-trash-can-outline</v-icon>
</v-btn>
</template>
<span>Delete</span>
</v-tooltip>
<v-tooltip bottom>
<template v-slot:activator="{ on }">
<v-btn
text
@click="remove(client)"
v-on="on"
>
<span class="d-none d-lg-flex">Delete</span>
<v-icon right dark>mdi-trash-can-outline</v-icon>
</v-btn>
</template>
<span>Delete</span>
</v-tooltip>

<v-tooltip bottom>
<template v-slot:activator="{ on }">
<v-btn
text
@click="sendEmailClient(client.id)"
v-on="on"
>
<span class="d-none d-lg-flex">Send Email</span>
<v-icon right dark>mdi-email-send-outline</v-icon>
</v-btn>
</template>
<span>Send Email</span>
</v-tooltip>
<v-tooltip bottom>
<template v-slot:activator="{ on }">
<v-btn
text
@click="email(client)"
v-on="on"
>
<span class="d-none d-lg-flex">Send Email</span>
<v-icon right dark>mdi-email-send-outline</v-icon>
</v-btn>
</template>
<span>Send Email</span>
</v-tooltip>
<v-spacer/>
<v-tooltip right>
<template v-slot:activator="{ on }">
@ -118,7 +118,7 @@
v-on="on"
color="success"
v-model="client.enable"
v-on:change="updateClient(client)"
v-on:change="update(client)"
/>
</template>
<span> {{client.enable ? 'Disable' : 'Enable'}} this client</span>
@ -133,7 +133,7 @@
</v-row>
<v-dialog
v-if="client"
v-model="dialogAddClient"
v-model="dialogCreate"
max-width="550"
>
<v-card>
@ -210,14 +210,14 @@
<v-btn
:disabled="!valid"
color="success"
@click="addClient(client)"
@click="create(client)"
>
Submit
<v-icon right dark>mdi-check-outline</v-icon>
</v-btn>
<v-btn
color="primary"
@click="dialogAddClient = false"
@click="dialogCreate = false"
>
Cancel
<v-icon right dark>mdi-close-circle-outline</v-icon>
@ -227,7 +227,7 @@
</v-dialog>
<v-dialog
v-if="client"
v-model="dialogEditClient"
v-model="dialogUpdate"
max-width="550"
>
<v-card>
@ -308,14 +308,14 @@
<v-btn
:disabled="!valid"
color="success"
@click="updateClient(client)"
@click="update(client)"
>
Submit
<v-icon right dark>mdi-check-outline</v-icon>
</v-btn>
<v-btn
color="primary"
@click="dialogEditClient = false"
@click="dialogUpdate = false"
>
Cancel
<v-icon right dark>mdi-close-circle-outline</v-icon>
@ -323,61 +323,50 @@
</v-card-actions>
</v-card>
</v-dialog>
<Notification v-bind:notification="notification"/>
</v-container>
</template>
<script>
import {ApiService, API_BASE_URL} from '../services/ApiService'
import Notification from '../components/Notification'
import { mapActions, mapGetters } from 'vuex'

export default {
name: 'Clients',

components: {
Notification
},

data: () => ({
api: null,
apiBaseUrl: API_BASE_URL,
clients: [],
notification: {
show: false,
color: '',
text: '',
},
dialogAddClient: false,
dialogEditClient: false,
dialogCreate: false,
dialogUpdate: false,
client: null,
server: null,
valid: false,
}),

computed:{
...mapGetters({
getClientQrcode: 'client/getClientQrcode',
getClientConfig: 'client/getClientConfig',
server: 'server/server',
clients: 'client/clients',
clientQrcodes: 'client/clientQrcodes',
}),
},

mounted () {
this.api = new ApiService();
this.getClients();
this.getServer()
this.readAllClients()
this.readServer()
},

methods: {
getClients() {
this.api.get('/client').then((res) => {
this.clients = res
}).catch((e) => {
this.notify('error', e.response.status + ' ' + e.response.statusText);
});
},
...mapActions('client', {
errorClient: 'error',
readAllClients: 'readAll',
creatClient: 'create',
updateClient: 'update',
deleteClient: 'delete',
emailClient: 'email',
}),
...mapActions('server', {
readServer: 'read',
}),

getServer() {
this.api.get('/server').then((res) => {
this.server = res;
}).catch((e) => {
this.notify('error', e.response.status + ' ' + e.response.statusText);
});
},

startAddClient() {
this.dialogAddClient = true;
startCreate() {
this.client = {
name: "",
email: "",
@ -385,91 +374,87 @@
allowedIPs: this.server.allowedips,
address: this.server.address,
}
this.dialogCreate = true;
},
addClient(client) {

create(client) {
if (client.allowedIPs.length < 1) {
this.notify('error', 'Please provide at least one valid CIDR address for client allowed IPs');
this.errorClient('Please provide at least one valid CIDR address for client allowed IPs')
return;
}
for (let i = 0; i < client.allowedIPs.length; i++){
if (this.$isCidr(client.allowedIPs[i]) === 0) {
this.notify('error', 'Invalid CIDR detected, please correct before submitting');
this.errorClient('Invalid CIDR detected, please correct before submitting')
return
}
}
this.dialogAddClient = false;

this.api.post('/client', client).then((res) => {
this.notify('success', `Client ${res.name} successfully added`);
this.getClients()
}).catch((e) => {
this.notify('error', e.response.status + ' ' + e.response.statusText);
});
this.dialogCreate = false;
this.creatClient(client)
},

deleteClient(client) {
remove(client) {
if(confirm(`Do you really want to delete ${client.name} ?`)){
this.api.delete(`/client/${client.id}`).then((res) => {
this.notify('success', "Client successfully deleted");
this.getClients()
}).catch((e) => {
this.notify('error', e.response.status + ' ' + e.response.statusText);
});
this.deleteClient(client)
}
},

sendEmailClient(id) {
this.api.get(`/client/${id}/email`).then((res) => {
this.notify('success', "Email successfully sent");
this.getClients()
}).catch((e) => {
this.notify('error', e.response.status + ' ' + e.response.statusText);
});
email(client) {
if (!client.email){
this.errorClient('Client email is not defined')
return
}

if(confirm(`Do you really want to send email to ${client.email} with all configurations ?`)){
this.emailClient(client)
}
},

startUpdateClient(client) {
startUpdate(client) {
this.client = client;
this.dialogEditClient = true;
this.dialogUpdate = true;
},
updateClient(client) {

update(client) {
// check allowed IPs
if (client.allowedIPs.length < 1) {
this.notify('error', 'Please provide at least one valid CIDR address for client allowed IPs');
this.errorClient('Please provide at least one valid CIDR address for client allowed IPs');
return;
}
for (let i = 0; i < client.allowedIPs.length; i++){
if (this.$isCidr(client.allowedIPs[i]) === 0) {
this.notify('error', 'Invalid CIDR detected, please correct before submitting');
this.errorClient('Invalid CIDR detected, please correct before submitting');
return
}
}
// check address
if (client.address.length < 1) {
this.notify('error', 'Please provide at least one valid CIDR address for client');
this.errorClient('Please provide at least one valid CIDR address for client');
return;
}
for (let i = 0; i < client.address.length; i++){
if (this.$isCidr(client.address[i]) === 0) {
this.notify('error', 'Invalid CIDR detected, please correct before submitting');
this.errorClient('Invalid CIDR detected, please correct before submitting');
return
}
}
// all good, submit
this.dialogEditClient = false;

this.api.patch(`/client/${client.id}`, client).then((res) => {
this.notify('success', `Client ${res.name} successfully updated`);
this.getClients()
}).catch((e) => {
this.notify('error', e.response.status + ' ' + e.response.statusText);
});
this.dialogUpdate = false;
this.updateClient(client)
},

notify(color, msg) {
this.notification.show = true;
this.notification.color = color;
this.notification.text = msg;
}
forceFileDownload(client){
let config = this.getClientConfig(client.id)
if (!config) {
this.errorClient('Failed to download client config');
return
}
const url = window.URL.createObjectURL(new Blob([config]))
const link = document.createElement('a')
link.href = url
link.setAttribute('download', 'wg0.conf') //or any other extension
document.body.appendChild(link)
link.click()
},
}
};
</script>

View File

@ -0,0 +1,51 @@
<template>
<v-container>
<v-footer app>
<v-row justify="start" no-gutters>
<v-col cols="12" lg="6" md="12" sm="12">
<div :align="$vuetify.breakpoint.smAndDown ? 'center' : 'left'">
<small>Copyright &copy; {{ new Date().getFullYear() }}, Wg Gen Web. </small>
<small>This work is licensed under <a class="pr-1 pl-1" href="http://www.wtfpl.net/" target="_blank">WTFPL License.</a></small>
</div>
</v-col>
</v-row>
<v-row justify="end" no-gutters>
<v-col cols="12" lg="6" md="12" sm="12">
<div :align="$vuetify.breakpoint.smAndDown ? 'center' : 'right'">
<small>Created with</small>
<v-icon class="pr-1 pl-1">mdi-heart</v-icon><span>by</span><a class="pr-2 pl-1" href="mailto:vx3r@127-0-0-1.fr">vx3r</a>
<a :href="'https://github.com/vx3r/wg-gen-web/commit/' + version"><kbd>Version: {{ version.substring(0,7) }}</kbd></a>
</div>
</v-col>
</v-row>
</v-footer>
</v-container>
</template>

<script>
import {mapActions, mapGetters} from "vuex";

export default {
name: 'Footer',

data: () => ({

}),

computed:{
...mapGetters({
version: 'server/version',
}),
},

mounted() {
this.versionServer()
},

methods: {
...mapActions('server', {
versionServer: 'version',
}),
}
}
</script>

View File

@ -0,0 +1,77 @@
<template>
<v-container>
<v-app-bar app>
<img class="mr-3" :src="require('../assets/logo.png')" height="50" alt="Wg Gen Web"/>
<v-toolbar-title to="/">Wg Gen Web</v-toolbar-title>

<v-spacer />

<v-toolbar-items>
<v-btn to="/clients">
Clients
<v-icon right dark>mdi-account-network-outline</v-icon>
</v-btn>
<v-btn to="/server">
Server
<v-icon right dark>mdi-vpn</v-icon>
</v-btn>
</v-toolbar-items>

<v-menu
left
bottom
>
<template v-slot:activator="{ on }">
<v-btn icon v-on="on">
<v-icon>mdi-account-circle</v-icon>
</v-btn>
</template>

<v-card
class="mx-auto"
max-width="344"
outlined
>
<v-list-item three-line>
<v-list-item-content>
<div class="overline mb-4">connected as</div>
<v-list-item-title class="headline mb-1">{{user.name}}</v-list-item-title>
<v-list-item-subtitle>Email: {{user.email}}</v-list-item-subtitle>
<v-list-item-subtitle>Issuer: {{user.issuer}}</v-list-item-subtitle>
<v-list-item-subtitle>Issued at: {{ user.issuedAt | formatDate }}</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
<v-card-actions>
<v-btn small
v-on:click="logout()"
>
logout
<v-icon small right dark>mdi-logout</v-icon>
</v-btn>
</v-card-actions>
</v-card>
</v-menu>

</v-app-bar>
</v-container>
</template>

<script>
import {mapActions, mapGetters} from "vuex";

export default {
name: 'Header',

computed:{
...mapGetters({
user: 'auth/user',
}),
},

methods: {
...mapActions('auth', {
logout: 'logout',
}),
}
}
</script>

View File

@ -158,7 +158,7 @@
<v-btn
class="ma-2"
color="success"
:href="`${apiBaseUrl}/server/config`"
v-on:click="forceFileDownload()"
>
Download server configuration
<v-icon right dark>mdi-cloud-download-outline</v-icon>
@ -167,52 +167,44 @@
<v-btn
class="ma-2"
color="warning"
@click="updateServer"
@click="update"
>
Update server configuration
<v-icon right dark>mdi-update</v-icon>
</v-btn>
<v-divider dark/>
</v-row>
<Notification v-bind:notification="notification"/>
</v-container>
</template>
<script>
import {API_BASE_URL, ApiService} from "../services/ApiService";
import Notification from '../components/Notification'
import {mapActions, mapGetters} from "vuex";

export default {
name: 'Server',

components: {
Notification
},

data: () => ({
api: null,
server: null,
apiBaseUrl: API_BASE_URL,
notification: {
show: false,
color: '',
text: '',
},

}),

computed:{
...mapGetters({
server: 'server/server',
config: 'server/config',
}),
},

mounted () {
this.api = new ApiService();
this.getServer()
this.readServer()
},

methods: {
getServer() {
this.api.get('/server').then((res) => {
this.server = res;
}).catch((e) => {
this.notify('error', e.response.status + ' ' + e.response.statusText);
});
},
updateServer () {
...mapActions('server', {
errorServer: 'error',
readServer: 'read',
updateServer: 'update',
}),

update() {
// convert int values
this.server.listenPort = parseInt(this.server.listenPort, 10);
this.server.persistentKeepalive = parseInt(this.server.persistentKeepalive, 10);
@ -220,12 +212,12 @@

// check server addresses
if (this.server.address.length < 1) {
this.notify('error', 'Please provide at least one valid CIDR address for server interface');
this.errorServer('Please provide at least one valid CIDR address for server interface');
return;
}
for (let i = 0; i < this.server.address.length; i++){
if (this.$isCidr(this.server.address[i]) === 0) {
this.notify('error', `Invalid CIDR detected, please correct ${this.server.address[i]} before submitting`);
this.errorServer(`Invalid CIDR detected, please correct ${this.server.address[i]} before submitting`);
return
}
}
@ -233,35 +225,34 @@
// check DNS correct
for (let i = 0; i < this.server.dns.length; i++){
if (this.$isCidr(this.server.dns[i] + '/32') === 0) {
this.notify('error', `Invalid IP detected, please correct ${this.server.dns[i]} before submitting`);
this.errorServer(`Invalid IP detected, please correct ${this.server.dns[i]} before submitting`);
return
}
}

// check client AllowedIPs
if (this.server.allowedips.length < 1) {
this.notify('error', 'Please provide at least one valid CIDR address for client allowed IPs');
this.errorServer('Please provide at least one valid CIDR address for client allowed IPs');
return;
}
for (let i = 0; i < this.server.allowedips.length; i++){
if (this.$isCidr(this.server.allowedips[i]) === 0) {
this.notify('error', 'Invalid CIDR detected, please correct before submitting');
this.errorServer('Invalid CIDR detected, please correct before submitting');
return
}
}

this.api.patch('/server', this.server).then((res) => {
this.notify('success', "Server successfully updated");
this.server = res;
}).catch((e) => {
this.notify('error', e.response.status + ' ' + e.response.statusText);
});
this.updateServer(this.server)
},

forceFileDownload(){
const url = window.URL.createObjectURL(new Blob([this.config]))
const link = document.createElement('a')
link.href = url
link.setAttribute('download', 'wg0.conf') //or any other extension
document.body.appendChild(link)
link.click()
},
notify(color, msg) {
this.notification.show = true;
this.notification.color = color;
this.notification.text = msg;
}
}
};
</script>

View File

@ -1,14 +1,18 @@
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
import vuetify from './plugins/vuetify';
import './plugins/moment';
import './plugins/cidr'
import './plugins/axios'

Vue.config.productionTip = false
// Don't warn about using the dev version of Vue in development.
Vue.config.productionTip = process.env.NODE_ENV === 'production'

new Vue({
router,
store,
vuetify,
render: function (h) { return h(App) }
}).$mount('#app')

26
ui/src/plugins/axios.js Normal file
View File

@ -0,0 +1,26 @@
import Vue from 'vue'
import axios from "axios";
import VueAxios from "vue-axios";
import TokenService from "../services/token.service";

Vue.use(VueAxios, axios);

let baseUrl = "/api/v1.0";
if (process.env.NODE_ENV === "development"){
baseUrl = process.env.VUE_APP_API_BASE_URL;
}

Vue.axios.defaults.baseURL = baseUrl;

Vue.axios.interceptors.response.use(function (response) {
return response;
}, function (error) {
if (401 === error.response.status) {
TokenService.destroyToken();
TokenService.destroyClientId();
window.location = '/';
} else {
return Promise.reject(error);
}
});

View File

@ -1,22 +1,19 @@
import Vue from 'vue'
import VueRouter from 'vue-router'
import store from "../store";

Vue.use(VueRouter);

const routes = [
{
path: '/',
name: 'index',
component: function () {
return import(/* webpackChunkName: "Index" */ '../views/Index.vue')
},
},
{
path: '/clients',
name: 'clients',
component: function () {
return import(/* webpackChunkName: "Clients" */ '../views/Clients.vue')
},
meta: {
requiresAuth: true
}
},
{
path: '/server',
@ -24,6 +21,9 @@ const routes = [
component: function () {
return import(/* webpackChunkName: "Server" */ '../views/Server.vue')
},
meta: {
requiresAuth: true
}
}
];

@ -33,4 +33,16 @@ const router = new VueRouter({
routes
});

router.beforeEach((to, from, next) => {
if(to.matched.some(record => record.meta.requiresAuth)) {
if (store.getters["auth/isAuthenticated"]) {
next()
return
}
next('/')
} else {
next()
}
})

export default router

View File

@ -1,40 +0,0 @@
import axios from 'axios'

let baseUrl = "/api/v1.0";
if (process.env.NODE_ENV === "development"){
baseUrl = process.env.VUE_APP_API_BASE_URL
}

export const API_BASE_URL = baseUrl;

export class ApiService {
get(resource) {
return axios
.get(`${API_BASE_URL}${resource}`)
.then(response => response.data)
};

post(resource, data) {
return axios
.post(`${API_BASE_URL}${resource}`, data)
.then(response => response.data)
};

put(resource, data) {
return axios
.put(`${API_BASE_URL}${resource}`, data)
.then(response => response.data)
};

patch(resource, data) {
return axios
.patch(`${API_BASE_URL}${resource}`, data)
.then(response => response.data)
};

delete(resource) {
return axios
.delete(`${API_BASE_URL}${resource}`)
.then(response => response.data)
};
}

View File

@ -0,0 +1,59 @@
import Vue from "vue";
import TokenService from "./token.service";

const ApiService = {

setHeader() {
Vue.axios.defaults.headers.common.Authorization = `${TokenService.getToken()}`;
},

get(resource) {
return Vue.axios.get(resource)
.then(response => response.data)
.catch(error => {
throw new Error(`ApiService: ${error}`)
});
},

post(resource, params) {
return Vue.axios.post(resource, params)
.then(response => response.data)
.catch(error => {
throw new Error(`ApiService: ${error}`)
});
},

put(resource, params) {
return Vue.axios.put(resource, params)
.then(response => response.data)
.catch(error => {
throw new Error(`ApiService: ${error}`)
});
},

patch(resource, params) {
return Vue.axios.patch(resource, params)
.then(response => response.data)
.catch(error => {
throw new Error(`ApiService: ${error}`)
});
},

delete(resource) {
return Vue.axios.delete(resource)
.then(response => response.data)
.catch(error => {
throw new Error(`ApiService: ${error}`)
});
},

getWithConfig(resource, config) {
return Vue.axios.get(resource, config)
.then(response => response.data)
.catch(error => {
throw new Error(`ApiService: ${error}`)
});
},
};

export default ApiService;

View File

@ -0,0 +1,35 @@
const TOKEN_KEY = "token";
const CLIENT_ID_KEY = "client_id";

export const getToken = () => {
return window.localStorage.getItem(TOKEN_KEY);
};

export const saveToken = token => {
window.localStorage.setItem(TOKEN_KEY, token);
};

export const destroyToken = () => {
window.localStorage.removeItem(TOKEN_KEY);
};

export const getClientId = () => {
return window.localStorage.getItem(CLIENT_ID_KEY);
};

export const saveClientId = token => {
window.localStorage.setItem(CLIENT_ID_KEY, token);
};

export const destroyClientId = () => {
window.localStorage.removeItem(CLIENT_ID_KEY);
};

export default {
getToken,
saveToken,
destroyToken,
getClientId,
saveClientId,
destroyClientId
};

19
ui/src/store/index.js Normal file
View File

@ -0,0 +1,19 @@
import Vue from 'vue'
import Vuex from 'vuex'
import auth from "./modules/auth";
import client from "./modules/client";
import server from "./modules/server";

Vue.use(Vuex)

export default new Vuex.Store({
state: {},
getters : {},
mutations: {},
actions:{},
modules: {
auth,
client,
server
}
})

View File

@ -0,0 +1,126 @@
import ApiService from "../../services/api.service";
import TokenService from "../../services/token.service";

const state = {
error: null,
user: null,
authStatus: '',
authRedirectUrl: '',
};

const getters = {
error(state) {
return state.error;
},
user(state) {
return state.user;
},
isAuthenticated(state) {
return state.user !== null;
},
authRedirectUrl(state) {
return state.authRedirectUrl
},
authStatus(state) {
return state.authStatus
},
};

const actions = {
user({ commit }){
ApiService.get("/auth/user")
.then( resp => {
commit('user', resp)
})
.catch(err => {
commit('error', err);
commit('logout')
});
},

oauth2_url({ commit, dispatch }){
if (TokenService.getToken()) {
ApiService.setHeader();
dispatch('user');
return
}
ApiService.get("/auth/oauth2_url")
.then(resp => {
if (resp.codeUrl === '_magic_string_fake_auth_no_redirect_'){
console.log("server report oauth2 is disabled, fake exchange")
commit('authStatus', 'disabled')
TokenService.saveClientId(resp.clientId)
dispatch('oauth2_exchange', {code: "", state: resp.state})
} else {
commit('authStatus', 'redirect')
commit('authRedirectUrl', resp)
}
})
.catch(err => {
commit('authStatus', 'error')
commit('error', err);
commit('logout')
})
},

oauth2_exchange({ commit, dispatch }, data){
data.clientId = TokenService.getClientId()
ApiService.post("/auth/oauth2_exchange", data)
.then(resp => {
commit('authStatus', 'success')
commit('token', resp)
dispatch('user');
})
.catch(err => {
commit('authStatus', 'error')
commit('error', err);
commit('logout')
})
},

logout({ commit }){
ApiService.get("/auth/logout")
.then(resp => {
commit('logout')
})
.catch(err => {
commit('authStatus', '')
commit('error', err);
commit('logout')
})
},
}

const mutations = {
error(state, error) {
state.error = error;
},
authStatus(state, authStatus) {
state.authStatus = authStatus;
},
authRedirectUrl(state, resp) {
state.authRedirectUrl = resp.codeUrl;
TokenService.saveClientId(resp.clientId);
},
token(state, token) {
TokenService.saveToken(token);
ApiService.setHeader();
TokenService.destroyClientId();
},
user(state, user) {
state.user = user;
},
logout(state) {
state.user = null;
TokenService.destroyToken();
TokenService.destroyClientId();
}
};

export default {
namespaced: true,
state,
getters,
actions,
mutations
}

View File

@ -0,0 +1,177 @@
import ApiService from "../../services/api.service";

const state = {
error: null,
clients: [],
clientQrcodes: [],
clientConfigs: []
}

const getters = {
error(state) {
return state.error;
},
clients(state) {
return state.clients;
},
getClientQrcode: (state) => (id) => {
let item = state.clientQrcodes.find(item => item.id === id)
// initial load fails, must wait promise and stuff...
return item ? item.qrcode : "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
},
getClientConfig: (state) => (id) => {
let item = state.clientConfigs.find(item => item.id === id)
return item ? item.config : null
}
}

const actions = {
error({ commit }, error){
commit('error', error)
},

readAll({ commit, dispatch }){
ApiService.get("/client")
.then(resp => {
commit('clients', resp)
dispatch('readQrcodes')
dispatch('readConfigs')
})
.catch(err => {
commit('error', err)
})
},

create({ commit, dispatch }, client){
ApiService.post("/client", client)
.then(resp => {
dispatch('readQrcode', resp)
dispatch('readConfig', resp)
commit('create', resp)
})
.catch(err => {
commit('error', err)
})
},

update({ commit, dispatch }, client){
ApiService.patch(`/client/${client.id}`, client)
.then(resp => {
dispatch('readQrcode', resp)
dispatch('readConfig', resp)
commit('update', resp)
})
.catch(err => {
commit('error', err)
})
},

delete({ commit }, client){
ApiService.delete(`/client/${client.id}`)
.then(() => {
commit('delete', client)
})
.catch(err => {
commit('error', err)
})
},

email({ commit }, client){
ApiService.get(`/client/${client.id}/email`)
.then(() => {
})
.catch(err => {
commit('error', err)
})
},

readQrcode({ state, commit }, client){
ApiService.getWithConfig(`/client/${client.id}/config?qrcode=true`, {responseType: 'arraybuffer'})
.then(resp => {
let image = Buffer.from(resp, 'binary').toString('base64')
commit('clientQrcodes', { client, image })
})
.catch(err => {
commit('error', err)
})
},

readConfig({ state, commit }, client){
ApiService.getWithConfig(`/client/${client.id}/config?qrcode=false`, {responseType: 'arraybuffer'})
.then(resp => {
commit('clientConfigs', { client: client, config: resp })
})
.catch(err => {
commit('error', err)
})
},

readQrcodes({ state, dispatch }){
state.clients.forEach(client => {
dispatch('readQrcode', client)
})
},

readConfigs({ state, dispatch }){
state.clients.forEach(client => {
dispatch('readConfig', client)
})
},
}

const mutations = {
error(state, error) {
state.error = error;
},
clients(state, clients){
state.clients = clients
},
create(state, client){
state.clients.push(client)
},
update(state, client){
let index = state.clients.findIndex(x => x.id === client.id);
if (index !== -1) {
state.clients.splice(index, 1);
state.clients.push(client);
} else {
state.error = "update client failed, not in list"
}
},
delete(state, client){
let index = state.clients.findIndex(x => x.id === client.id);
if (index !== -1) {
state.clients.splice(index, 1);
} else {
state.error = "delete client failed, not in list"
}
},
clientQrcodes(state, { client, image }){
let index = state.clientQrcodes.findIndex(x => x.id === client.id);
if (index !== -1) {
state.clientQrcodes.splice(index, 1);
}
state.clientQrcodes.push({
id: client.id,
qrcode: image
})
},
clientConfigs(state, { client, config }){
let index = state.clientConfigs.findIndex(x => x.id === client.id);
if (index !== -1) {
state.clientConfigs.splice(index, 1);
}
state.clientConfigs.push({
id: client.id,
config: config
})
},
}

export default {
namespaced: true,
state,
getters,
actions,
mutations
}

View File

@ -0,0 +1,100 @@
import ApiService from "../../services/api.service";

const state = {
error: null,
server: null,
config: '',
version: '_ci_build_not_run_properly_',
}

const getters = {
error(state) {
return state.error;
},

server(state) {
return state.server;
},

version(state) {
return state.version;
},

config(state) {
return state.config;
},
}

const actions = {
error({ commit }, error){
commit('error', error)
},

read({ commit, dispatch }){
ApiService.get("/server")
.then(resp => {
commit('server', resp)
dispatch('config')
})
.catch(err => {
commit('error', err)
})
},

update({ commit }, server){
ApiService.patch(`/server`, server)
.then(resp => {
commit('server', resp)
})
.catch(err => {
commit('error', err)
})
},

config({ commit }){
ApiService.getWithConfig("/server/config", {responseType: 'arraybuffer'})
.then(resp => {
commit('config', resp)
})
.catch(err => {
commit('error', err)
})
},

version({ commit }){
ApiService.get("/server/version")
.then(resp => {
commit('version', resp.version)
})
.catch(err => {
commit('error', err)
})
},

}

const mutations = {
error(state, error) {
state.error = error;
},

server(state, server){
state.server = server
},

config(state, config){
state.config = config
},

version(state, version){
state.version = version
},
}

export default {
namespaced: true,
state,
getters,
actions,
mutations
}

View File

@ -1,10 +0,0 @@
<template>
</template>

<script>
export default {
created () {
this.$router.replace({ name: 'clients' })
}
}
</script>

View File

@ -1,6 +1,8 @@
package util

import (
"crypto/rand"
"encoding/base64"
"errors"
"io/ioutil"
"net"
@ -9,6 +11,7 @@ import (
)

var (
AuthTokenHeaderName = "Authorization"
// RegexpEmail check valid email
RegexpEmail = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$")
)
@ -51,7 +54,7 @@ func DirectoryExists(name string) bool {
return info.IsDir()
}

// GetAvailableIp search for an available in cidr against a list of reserved ips
// GetAvailableIp search for an available ip in cidr against a list of reserved ips
func GetAvailableIp(cidr string, reserved []string) (string, error) {
ip, ipnet, err := net.ParseCIDR(cidr)
if err != nil {
@ -132,3 +135,28 @@ func BroadcastAddr(n *net.IPNet) net.IP {
}
return broadcast
}

// GenerateRandomBytes returns securely generated random bytes.
// It will return an error if the system's secure random
// number generator fails to function correctly, in which
// case the caller should not continue.
func GenerateRandomBytes(n int) ([]byte, error) {
b := make([]byte, n)
_, err := rand.Read(b)
// Note that err == nil only if we read len(b) bytes.
if err != nil {
return nil, err
}

return b, nil
}

// GenerateRandomString returns a URL-safe, base64 encoded
// securely generated random string.
// It will return an error if the system's secure random
// number generator fails to function correctly, in which
// case the caller should not continue.
func GenerateRandomString(s int) (string, error) {
b, err := GenerateRandomBytes(s)
return base64.URLEncoding.EncodeToString(b), err
}