chore: initial project scaffold with admin web, backend, desktop client, and deployment setup

Add monorepo structure for NexaVPN WireGuard control plane including:
- .gitignore for node_modules, build artifacts, and environment files
- README with project overview, monorepo layout, and quick start guide
- Admin web UI with React, Vite, TypeScript, and nginx reverse proxy
- API client with type definitions for users, devices, policies, gateways, and audit logs
- Admin pages for dashboard, users, devices, policies, g
This commit is contained in:
2026-03-15 16:32:34 +01:00
commit 830491cb0d
91 changed files with 5279 additions and 0 deletions

View File

@@ -0,0 +1,137 @@
package auth
import (
"encoding/json"
"net/http"
"github.com/nexavpn/nexavpn/backend/internal/apiutil"
"github.com/nexavpn/nexavpn/backend/internal/audit"
"github.com/nexavpn/nexavpn/backend/internal/httpserver"
)
type Handler struct {
service *Service
audit *audit.Service
}
func NewHandler(service *Service, auditService *audit.Service) *Handler {
return &Handler{service: service, audit: auditService}
}
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
var input LoginRequest
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
apiutil.Error(w, http.StatusBadRequest, "invalid_json", "invalid request body")
return
}
response, err := h.service.Login(r.Context(), input.Username, input.Password, r.RemoteAddr, r.UserAgent())
if err != nil {
_ = h.audit.Record(r.Context(), audit.Entry{
EventType: "auth.login.failed",
EntityType: "user",
Status: "failed",
Message: "user login failed",
Metadata: map[string]any{
"username": input.Username,
},
})
apiutil.Error(w, http.StatusUnauthorized, "invalid_credentials", "invalid username or password")
return
}
_ = h.audit.Record(r.Context(), audit.Entry{
ActorUserID: &response.User.ID,
EventType: "auth.login",
EntityType: "user",
EntityID: &response.User.ID,
Status: "success",
Message: "user login succeeded",
})
apiutil.JSON(w, http.StatusOK, response)
}
func (h *Handler) Bootstrap(w http.ResponseWriter, r *http.Request) {
var input BootstrapRequest
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
apiutil.Error(w, http.StatusBadRequest, "invalid_json", "invalid request body")
return
}
if input.Username == "" || input.Password == "" {
apiutil.Error(w, http.StatusBadRequest, "validation_error", "username and password are required")
return
}
if input.DisplayName == "" {
input.DisplayName = input.Username
}
user, err := h.service.BootstrapAdmin(r.Context(), input.Username, input.DisplayName, input.Password)
if err != nil {
apiutil.Error(w, http.StatusConflict, "bootstrap_failed", "initial admin already exists or could not be created")
return
}
_ = h.audit.Record(r.Context(), audit.Entry{
ActorUserID: &user.ID,
EntityType: "user",
EntityID: &user.ID,
EventType: "system.bootstrap_admin",
Status: "success",
Message: "initial admin account created",
})
apiutil.JSON(w, http.StatusCreated, user)
}
func (h *Handler) Refresh(w http.ResponseWriter, r *http.Request) {
var input RefreshRequest
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
apiutil.Error(w, http.StatusBadRequest, "invalid_json", "invalid request body")
return
}
response, err := h.service.Refresh(r.Context(), input.RefreshToken)
if err != nil {
apiutil.Error(w, http.StatusUnauthorized, "invalid_refresh_token", "unable to refresh session")
return
}
apiutil.JSON(w, http.StatusOK, response)
}
func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
var input RefreshRequest
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
apiutil.Error(w, http.StatusBadRequest, "invalid_json", "invalid request body")
return
}
if err := h.service.Logout(r.Context(), input.RefreshToken); err != nil {
apiutil.Error(w, http.StatusBadRequest, "logout_failed", "unable to revoke session")
return
}
if claims, ok := httpserver.ClaimsFromContext(r.Context()); ok {
_ = h.audit.Record(r.Context(), audit.Entry{
ActorUserID: &claims.UserID,
EventType: "auth.logout",
EntityType: "session",
Status: "success",
Message: "session logout succeeded",
})
}
apiutil.JSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (h *Handler) Me(w http.ResponseWriter, r *http.Request) {
claims, ok := httpserver.ClaimsFromContext(r.Context())
if !ok {
apiutil.Error(w, http.StatusUnauthorized, "unauthorized", "missing auth claims")
return
}
apiutil.JSON(w, http.StatusOK, map[string]any{
"id": claims.UserID,
"username": claims.Username,
"role": claims.Role,
})
}

View File

@@ -0,0 +1,11 @@
package auth
import (
"crypto/sha256"
"encoding/base64"
)
func base64Hash(value string) string {
sum := sha256.Sum256([]byte(value))
return base64.RawURLEncoding.EncodeToString(sum[:])
}

View File

@@ -0,0 +1,40 @@
package auth
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"fmt"
"strings"
"golang.org/x/crypto/argon2"
)
func HashPassword(password string) (string, error) {
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
return "", err
}
hash := argon2.IDKey([]byte(password), salt, 1, 64*1024, 4, 32)
return fmt.Sprintf("argon2id$%s$%s", base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(hash)), nil
}
func VerifyPassword(hashValue, password string) bool {
parts := strings.Split(hashValue, "$")
if len(parts) != 3 || parts[0] != "argon2id" {
return false
}
salt, err := base64.RawStdEncoding.DecodeString(parts[1])
if err != nil {
return false
}
expected, err := base64.RawStdEncoding.DecodeString(parts[2])
if err != nil {
return false
}
actual := argon2.IDKey([]byte(password), salt, 1, 64*1024, 4, 32)
return subtle.ConstantTimeCompare(expected, actual) == 1
}

View File

@@ -0,0 +1,105 @@
package auth
import (
"context"
"errors"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
type PGRepository struct {
db *pgxpool.Pool
}
func NewPGRepository(db *pgxpool.Pool) *PGRepository {
return &PGRepository{db: db}
}
func (r *PGRepository) FindUserByUsername(ctx context.Context, username string) (UserRecord, error) {
const query = `
select u.id, u.username, u.display_name, r.name, u.password_hash, u.is_active
from users u
join roles r on r.id = u.role_id
where u.username = $1 and u.deleted_at is null
`
row := r.db.QueryRow(ctx, query, username)
record := UserRecord{}
if err := row.Scan(&record.ID, &record.Username, &record.DisplayName, &record.Role, &record.PasswordHash, &record.IsActive); err != nil {
return UserRecord{}, err
}
return record, nil
}
func (r *PGRepository) CreateSession(ctx context.Context, userID uuid.UUID, expiresAt time.Time, ipAddress string, userAgent string) (uuid.UUID, error) {
const query = `
insert into sessions (id, user_id, ip_address, user_agent, expires_at)
values ($1, $2, nullif($3, '')::inet, $4, $5)
`
id := uuid.New()
_, err := r.db.Exec(ctx, query, id, userID, ipAddress, userAgent, expiresAt)
return id, err
}
func (r *PGRepository) StoreRefreshToken(ctx context.Context, sessionID uuid.UUID, userID uuid.UUID, tokenHash string, expiresAt time.Time) error {
const query = `
insert into refresh_tokens (id, session_id, user_id, token_hash, expires_at)
values ($1, $2, $3, $4, $5)
`
_, err := r.db.Exec(ctx, query, uuid.New(), sessionID, userID, tokenHash, expiresAt)
return err
}
func (r *PGRepository) FindRefreshToken(ctx context.Context, tokenHash string) (UserRecord, uuid.UUID, error) {
const query = `
select u.id, u.username, u.display_name, roles.name, u.password_hash, u.is_active, rt.session_id
from refresh_tokens rt
join users u on u.id = rt.user_id
join roles on roles.id = u.role_id
where rt.token_hash = $1 and rt.revoked_at is null and rt.expires_at > now()
`
record := UserRecord{}
var sessionID uuid.UUID
row := r.db.QueryRow(ctx, query, tokenHash)
if err := row.Scan(&record.ID, &record.Username, &record.DisplayName, &record.Role, &record.PasswordHash, &record.IsActive, &sessionID); err != nil {
return UserRecord{}, uuid.Nil, err
}
if !record.IsActive {
return UserRecord{}, uuid.Nil, errors.New("user inactive")
}
return record, sessionID, nil
}
func (r *PGRepository) RevokeRefreshToken(ctx context.Context, tokenHash string) error {
const query = `update refresh_tokens set revoked_at = now() where token_hash = $1 and revoked_at is null`
_, err := r.db.Exec(ctx, query, tokenHash)
return err
}
func (r *PGRepository) HasUsers(ctx context.Context) (bool, error) {
var count int
if err := r.db.QueryRow(ctx, `select count(*) from users where deleted_at is null`).Scan(&count); err != nil {
return false, err
}
return count > 0, nil
}
func (r *PGRepository) CreateBootstrapAdmin(ctx context.Context, username, displayName, passwordHash string) (UserRecord, error) {
const query = `
insert into users (id, role_id, username, display_name, password_hash, is_active)
values ($1, (select id from roles where name = 'admin'), $2, $3, $4, true)
returning id, username, display_name, password_hash, is_active
`
record := UserRecord{Role: "admin"}
err := r.db.QueryRow(ctx, query, uuid.New(), username, displayName, passwordHash).
Scan(&record.ID, &record.Username, &record.DisplayName, &record.PasswordHash, &record.IsActive)
return record, err
}

View File

@@ -0,0 +1,155 @@
package auth
import (
"context"
"errors"
"time"
"github.com/google/uuid"
)
var ErrInvalidCredentials = errors.New("invalid credentials")
type UserRecord struct {
ID uuid.UUID
Username string
DisplayName string
Role string
PasswordHash string
IsActive bool
}
type Repository interface {
FindUserByUsername(ctx context.Context, username string) (UserRecord, error)
CreateSession(ctx context.Context, userID uuid.UUID, expiresAt time.Time, ipAddress string, userAgent string) (uuid.UUID, error)
StoreRefreshToken(ctx context.Context, sessionID uuid.UUID, userID uuid.UUID, tokenHash string, expiresAt time.Time) error
FindRefreshToken(ctx context.Context, tokenHash string) (UserRecord, uuid.UUID, error)
RevokeRefreshToken(ctx context.Context, tokenHash string) error
HasUsers(ctx context.Context) (bool, error)
CreateBootstrapAdmin(ctx context.Context, username, displayName, passwordHash string) (UserRecord, error)
}
type Service struct {
repo Repository
jwtSecret string
jwtIssuer string
accessTokenTTL time.Duration
refreshTokenTTL time.Duration
}
func NewService(repo Repository, jwtSecret, jwtIssuer string, accessTokenTTL, refreshTokenTTL time.Duration) *Service {
return &Service{
repo: repo,
jwtSecret: jwtSecret,
jwtIssuer: jwtIssuer,
accessTokenTTL: accessTokenTTL,
refreshTokenTTL: refreshTokenTTL,
}
}
func (s *Service) Login(ctx context.Context, username, password, ipAddress, userAgent string) (LoginResponse, error) {
record, err := s.repo.FindUserByUsername(ctx, username)
if err != nil || !record.IsActive || !VerifyPassword(record.PasswordHash, password) {
return LoginResponse{}, ErrInvalidCredentials
}
sessionID, err := s.repo.CreateSession(ctx, record.ID, time.Now().Add(s.refreshTokenTTL), ipAddress, userAgent)
if err != nil {
return LoginResponse{}, err
}
plainRefresh, hashedRefresh, err := NewRefreshToken()
if err != nil {
return LoginResponse{}, err
}
if err := s.repo.StoreRefreshToken(ctx, sessionID, record.ID, hashedRefresh, time.Now().Add(s.refreshTokenTTL)); err != nil {
return LoginResponse{}, err
}
access, err := SignAccessToken(s.jwtSecret, s.jwtIssuer, s.accessTokenTTL, Claims{
UserID: record.ID,
Username: record.Username,
Role: record.Role,
Session: sessionID,
})
if err != nil {
return LoginResponse{}, err
}
return LoginResponse{
AccessToken: access,
RefreshToken: plainRefresh,
ExpiresIn: int64(s.accessTokenTTL.Seconds()),
User: UserView{
ID: record.ID,
Username: record.Username,
DisplayName: record.DisplayName,
Role: record.Role,
},
}, nil
}
func (s *Service) Refresh(ctx context.Context, refreshToken string) (LoginResponse, error) {
record, sessionID, err := s.repo.FindRefreshToken(ctx, hashToken(refreshToken))
if err != nil {
return LoginResponse{}, ErrInvalidCredentials
}
access, err := SignAccessToken(s.jwtSecret, s.jwtIssuer, s.accessTokenTTL, Claims{
UserID: record.ID,
Username: record.Username,
Role: record.Role,
Session: sessionID,
})
if err != nil {
return LoginResponse{}, err
}
return LoginResponse{
AccessToken: access,
RefreshToken: refreshToken,
ExpiresIn: int64(s.accessTokenTTL.Seconds()),
User: UserView{
ID: record.ID,
Username: record.Username,
DisplayName: record.DisplayName,
Role: record.Role,
},
}, nil
}
func (s *Service) Logout(ctx context.Context, refreshToken string) error {
return s.repo.RevokeRefreshToken(ctx, hashToken(refreshToken))
}
func (s *Service) BootstrapAdmin(ctx context.Context, username, displayName, password string) (UserView, error) {
hasUsers, err := s.repo.HasUsers(ctx)
if err != nil {
return UserView{}, err
}
if hasUsers {
return UserView{}, errors.New("bootstrap already completed")
}
passwordHash, err := HashPassword(password)
if err != nil {
return UserView{}, err
}
record, err := s.repo.CreateBootstrapAdmin(ctx, username, displayName, passwordHash)
if err != nil {
return UserView{}, err
}
return UserView{
ID: record.ID,
Username: record.Username,
DisplayName: record.DisplayName,
Role: record.Role,
}, nil
}
func hashToken(plain string) string {
return base64Hash(plain)
}

View File

@@ -0,0 +1,77 @@
package auth
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
)
func NewRefreshToken() (plain string, hashed string, err error) {
raw := make([]byte, 32)
if _, err = rand.Read(raw); err != nil {
return "", "", err
}
plain = base64.RawURLEncoding.EncodeToString(raw)
sum := sha256.Sum256([]byte(plain))
hashed = base64.RawURLEncoding.EncodeToString(sum[:])
return plain, hashed, nil
}
func SignAccessToken(secret, issuer string, ttl time.Duration, claims Claims) (string, error) {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"iss": issuer,
"sub": claims.UserID.String(),
"username": claims.Username,
"role": claims.Role,
"session_id": claims.Session.String(),
"exp": time.Now().Add(ttl).Unix(),
"iat": time.Now().Unix(),
})
return token.SignedString([]byte(secret))
}
func ParseAccessToken(secret string, tokenString string) (Claims, error) {
claims := Claims{}
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (any, error) {
return []byte(secret), nil
})
if err != nil || !token.Valid {
return claims, err
}
mapClaims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return claims, jwt.ErrTokenMalformed
}
subject, ok := mapClaims["sub"].(string)
if !ok {
return claims, jwt.ErrTokenMalformed
}
sessionValue, ok := mapClaims["session_id"].(string)
if !ok {
return claims, jwt.ErrTokenMalformed
}
userID, err := uuid.Parse(subject)
if err != nil {
return claims, err
}
sessionID, err := uuid.Parse(sessionValue)
if err != nil {
return claims, err
}
claims.UserID = userID
claims.Session = sessionID
claims.Username, _ = mapClaims["username"].(string)
claims.Role, _ = mapClaims["role"].(string)
return claims, nil
}

View File

@@ -0,0 +1,39 @@
package auth
import "github.com/google/uuid"
type Claims struct {
UserID uuid.UUID `json:"user_id"`
Username string `json:"username"`
Role string `json:"role"`
Session uuid.UUID `json:"session_id"`
}
type LoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
type RefreshRequest struct {
RefreshToken string `json:"refresh_token"`
}
type BootstrapRequest struct {
Username string `json:"username"`
DisplayName string `json:"display_name"`
Password string `json:"password"`
}
type LoginResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int64 `json:"expires_in"`
User UserView `json:"user"`
}
type UserView struct {
ID uuid.UUID `json:"id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
Role string `json:"role"`
}