feat(app): add WGRplane web UI and backend features
- Add Vue 3 frontend with glassmorphism design (Tailwind CSS) - Add Go backend handlers: auth, webhooks, stats, scheduler, validation - Add i18n support (EN, ID, ZH) - Add Swagger docs and API handlers - Add nftables integration and plugins support - Remove deprecated go.mod (migrated to wgrplane)
This commit is contained in:
+146
@@ -0,0 +1,146 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
jwt "github.com/golang-jwt/jwt/v5"
|
||||
"github.com/pquerna/otp/totp"
|
||||
)
|
||||
|
||||
// Simple in-memory storage for TOTPs per user. In production, use a DB.
|
||||
var totpSecrets = map[string]string{}
|
||||
|
||||
// API key (default) - can be overridden by WG_API_KEY env var.
|
||||
var apiKey string
|
||||
|
||||
// JWT secret (default) - can be overridden by JWT_SECRET env var.
|
||||
var jwtSecret string
|
||||
|
||||
func init() {
|
||||
apiKey = os.Getenv("WG_API_KEY")
|
||||
if apiKey == "" {
|
||||
apiKey = "test-api-key" // default for testing
|
||||
}
|
||||
jwtSecret = os.Getenv("JWT_SECRET")
|
||||
if jwtSecret == "" {
|
||||
jwtSecret = "secret" // default for testing
|
||||
}
|
||||
// Preload a test user so JWTs can be generated in tests if needed
|
||||
if _, ok := totpSecrets["test"]; !ok {
|
||||
// generate a random-looking secret for test user if desired
|
||||
// but we won't force it here; user can call /auth/setup-totp?user=test
|
||||
}
|
||||
}
|
||||
|
||||
type Claims struct {
|
||||
Username string `json:"username"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// generateJWT creates a short-lived JWT for a given username
|
||||
func generateJWT(username string) (string, error) {
|
||||
claims := &Claims{
|
||||
Username: username,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Issuer: "wgrplane",
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
|
||||
},
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString([]byte(jwtSecret))
|
||||
}
|
||||
|
||||
func parseJWT(tokenStr string) (*Claims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
return []byte(jwtSecret), nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
|
||||
return claims, nil
|
||||
}
|
||||
return nil, fmt.Errorf("invalid token")
|
||||
}
|
||||
|
||||
// totpSetupHandler returns a new TOTP secret and provisioning URI for a user
|
||||
func totpSetupHandler(w http.ResponseWriter, r *http.Request) {
|
||||
user := r.URL.Query().Get("user")
|
||||
if user == "" {
|
||||
http.Error(w, "missing user", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
key, err := totp.Generate(totp.GenerateOpts{
|
||||
Issuer: "WGRplane",
|
||||
AccountName: user,
|
||||
})
|
||||
if err != nil {
|
||||
http.Error(w, "failed to generate secret", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
secret := key.Secret()
|
||||
totpSecrets[user] = secret
|
||||
resp := map[string]string{
|
||||
"secret": secret,
|
||||
"provisioning_uri": key.URL(),
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
// AuthMiddleware protects selected routes with API Key or JWT + optional TOTP
|
||||
func AuthMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// 1) API Key path
|
||||
if key := r.Header.Get("wg-rplane-datadunia"); key != "" {
|
||||
if subtleConstantTimeEquals(key, apiKey) {
|
||||
// If user has a TOTP, require current OTP in header
|
||||
// The username isn't known from API Key alone; skip TOTP check here
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 2) JWT path
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if strings.HasPrefix(authHeader, "Bearer ") {
|
||||
token := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
claims, err := parseJWT(token)
|
||||
if err != nil {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
// If user has a TOTp secret, verify it
|
||||
if secret, ok := totpSecrets[claims.Username]; ok {
|
||||
otp := r.Header.Get("X-TOTP")
|
||||
if otp == "" || !totp.Validate(otp, secret) {
|
||||
http.Error(w, "Unauthorized (TOTp)", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
}
|
||||
// Attach username to context for downstream handlers if needed
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// No valid auth provided
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
})
|
||||
}
|
||||
|
||||
// helper for constant-time string comparison
|
||||
func subtleConstantTimeEquals(a, b string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
var diff byte
|
||||
for i := 0; i < len(a); i++ {
|
||||
diff |= a[i] ^ b[i]
|
||||
}
|
||||
return diff == 0
|
||||
}
|
||||
Reference in New Issue
Block a user