chore: Adds user creation route.

This commit is contained in:
imterah 2024-12-22 00:56:15 -05:00
parent c7b71e754f
commit 1237a31f8b
Signed by: imterah
GPG key ID: 8FA7DD57BA6CEA37
29 changed files with 630 additions and 63 deletions

View file

@ -0,0 +1,26 @@
package constants
var DefaultPermissionNodes []string = []string{
"routes.add",
"routes.remove",
"routes.start",
"routes.stop",
"routes.edit",
"routes.visible",
"routes.visibleConn",
"backends.add",
"backends.remove",
"backends.start",
"backends.stop",
"backends.edit",
"backends.visible",
"backends.secretVis",
"permissions.see",
"users.add",
"users.remove",
"users.lookup",
"users.edit",
}

View file

@ -0,0 +1,176 @@
package users
import (
"crypto/rand"
"encoding/base64"
"fmt"
"net/http"
"os"
"strings"
"github.com/go-playground/validator/v10"
"git.terah.dev/imterah/hermes/api/constants"
"git.terah.dev/imterah/hermes/api/dbcore"
"git.terah.dev/imterah/hermes/api/jwtcore"
"github.com/charmbracelet/log"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
)
type UserCreationRequest struct {
Name string `validate:"required"`
Email string `validate:"required"`
Password string `validate:"required"`
Username string `validate:"required"`
// TODO: implement support
ExistingUserToken string `json:"token"`
IsBot bool
}
var (
signupEnabled bool
unsafeSignup bool
forceNoExpiryTokens bool
)
func init() {
signupEnabled = os.Getenv("HERMES_SIGNUP_ENABLED") != ""
unsafeSignup = os.Getenv("HERMES_UNSAFE_ADMIN_SIGNUP_ENABLED") != ""
forceNoExpiryTokens = os.Getenv("HERMES_FORCE_DISABLE_REFRESH_TOKEN_EXPIRY") != ""
}
func CreateUser(c *gin.Context) {
var req UserCreationRequest
if !signupEnabled && !unsafeSignup {
c.JSON(http.StatusForbidden, gin.H{
"error": "Signing up is not enabled at this time.",
})
return
}
if err := c.BindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Failed to parse body: %s", err.Error()),
})
return
}
if err := validator.New().Struct(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Failed to validate body: %s", err.Error()),
})
return
}
var user *dbcore.User
userRequest := dbcore.DB.Where("email = ? OR username = ?", req.Email, req.Username).Find(&user)
if userRequest.Error != nil {
log.Warnf("failed to find if user exists or not: %s", userRequest.Error)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to find if user exists",
})
return
}
userExists := userRequest.RowsAffected > 0
if userExists {
c.JSON(http.StatusBadRequest, gin.H{
"error": "User already exists",
})
return
}
passwordHashed, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
log.Warnf("Failed to generate password for client upon signup: %s", err.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to generate password hash",
})
return
}
permissions := []dbcore.Permission{}
for _, permission := range constants.DefaultPermissionNodes {
permissionEnabledState := false
if unsafeSignup || strings.HasPrefix(permission, "routes.") || permission == "permissions.see" {
permissionEnabledState = true
}
permissions = append(permissions, dbcore.Permission{
PermissionNode: permission,
HasPermission: permissionEnabledState,
})
}
tokenRandomData := make([]byte, 80)
if _, err := rand.Read(tokenRandomData); err != nil {
log.Warnf("Failed to read random data to use as token: %s", err.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to generate refresh token",
})
return
}
user = &dbcore.User{
Email: req.Email,
Username: req.Username,
Name: req.Name,
IsBot: &req.IsBot,
Password: base64.StdEncoding.EncodeToString(passwordHashed),
Permissions: permissions,
Tokens: []dbcore.Token{
{
Token: base64.StdEncoding.EncodeToString(tokenRandomData),
DisableExpiry: forceNoExpiryTokens,
CreationIPAddr: c.ClientIP(),
},
},
}
if result := dbcore.DB.Create(&user); result.Error != nil {
log.Warnf("Failed to create user: %s", result.Error.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to add user into database",
})
return
}
jwt, err := jwtcore.Generate(user.ID)
if err != nil {
log.Warnf("Failed to generate JWT: %s", err.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to generate refresh token",
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"token": jwt,
"refreshToken": base64.StdEncoding.EncodeToString(tokenRandomData),
})
}

133
backend/api/dbcore/db.go Normal file
View file

@ -0,0 +1,133 @@
package dbcore
import (
"fmt"
"os"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
type Backend struct {
gorm.Model
UserID uint
Name string
Description *string
Backend string
BackendParameters string
Proxies []Proxy
}
type Proxy struct {
gorm.Model
BackendID uint
UserID uint
Name string
Description *string
Protocol string
SourceIP string
SourcePort uint16
DestinationPort uint16
AutoStart bool
}
type Permission struct {
gorm.Model
PermissionNode string
HasPermission bool
UserID uint
}
type Token struct {
gorm.Model
UserID uint
Token string
DisableExpiry bool
CreationIPAddr string
}
type User struct {
gorm.Model
Email string `gorm:"unique"`
Username string `gorm:"unique"`
Name string
Password string
IsBot *bool
Permissions []Permission
OwnedProxies []Proxy
OwnedBackends []Backend
Tokens []Token
}
var DB *gorm.DB
func InitializeDatabaseDialector() (gorm.Dialector, error) {
databaseBackend := os.Getenv("HERMES_DATABASE_BACKEND")
switch databaseBackend {
case "sqlite":
filePath := os.Getenv("HERMES_SQLITE_FILEPATH")
if filePath == "" {
return nil, fmt.Errorf("sqlite database file not specified (missing HERMES_SQLITE_FILEPATH)")
}
return sqlite.Open(filePath), nil
case "":
return nil, fmt.Errorf("no database backend specified in environment variables (missing HERMES_DATABASE_BACKEND)")
default:
return nil, fmt.Errorf("unknown database backend specified: %s", os.Getenv(databaseBackend))
}
}
func InitializeDatabase(config *gorm.Config) error {
var err error
dialector, err := InitializeDatabaseDialector()
if err != nil {
return fmt.Errorf("failed to initialize physical database: %s", err)
}
DB, err = gorm.Open(dialector, config)
if err != nil {
return fmt.Errorf("failed to open database: %s", err)
}
return nil
}
func DoDatabaseMigrations(db *gorm.DB) error {
if err := db.AutoMigrate(&Proxy{}); err != nil {
return err
}
if err := db.AutoMigrate(&Backend{}); err != nil {
return err
}
if err := db.AutoMigrate(&Permission{}); err != nil {
return err
}
if err := db.AutoMigrate(&Token{}); err != nil {
return err
}
if err := db.AutoMigrate(&User{}); err != nil {
return err
}
return nil
}

View file

@ -0,0 +1,59 @@
package jwtcore
import (
"encoding/base64"
"fmt"
"os"
"strconv"
"time"
"github.com/golang-jwt/jwt/v5"
)
var JWTKey []byte
func SetupJWT() error {
var err error
jwtDataString := os.Getenv("HERMES_JWT_SECRET")
if jwtDataString == "" {
return fmt.Errorf("JWT secret isn't set (missing HERMES_JWT_SECRET)")
}
if os.Getenv("HERMES_JWT_BASE64_ENCODED") != "" {
JWTKey, err = base64.StdEncoding.DecodeString(jwtDataString)
if err != nil {
return fmt.Errorf("failed to decode base64 JWT: %s", err.Error())
}
} else {
JWTKey = []byte(jwtDataString)
}
return nil
}
func Parse(tokenString string, options ...jwt.ParserOption) (*jwt.Token, error) {
return jwt.Parse(tokenString, JWTKeyCallback, options...)
}
func Generate(uid uint) (string, error) {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(3 * time.Minute)),
IssuedAt: jwt.NewNumericDate(time.Now()),
NotBefore: jwt.NewNumericDate(time.Now()),
Audience: []string{strconv.Itoa(int(uid))},
})
signedToken, err := token.SignedString(JWTKey)
if err != nil {
return "", err
}
return signedToken, nil
}
func JWTKeyCallback(*jwt.Token) (interface{}, error) {
return JWTKey, nil
}

102
backend/api/main.go Normal file
View file

@ -0,0 +1,102 @@
package main
import (
"os"
"strings"
"git.terah.dev/imterah/hermes/api/controllers/v1/users"
"git.terah.dev/imterah/hermes/api/dbcore"
"git.terah.dev/imterah/hermes/api/jwtcore"
"github.com/charmbracelet/log"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
func main() {
logLevel := os.Getenv("HERMES_LOG_LEVEL")
developmentMode := false
if os.Getenv("HERMES_DEVELOPMENT_MODE") != "" {
developmentMode = true
}
if logLevel != "" {
switch logLevel {
case "debug":
log.SetLevel(log.DebugLevel)
case "info":
log.SetLevel(log.InfoLevel)
case "warn":
log.SetLevel(log.WarnLevel)
case "error":
log.SetLevel(log.ErrorLevel)
case "fatal":
log.SetLevel(log.FatalLevel)
}
}
log.Info("Hermes is initializing...")
log.Debug("Initializing database and opening it...")
err := dbcore.InitializeDatabase(&gorm.Config{})
if err != nil {
log.Fatalf("Failed to initialize database: %s", err)
}
log.Debug("Running database migrations...")
if err := dbcore.DoDatabaseMigrations(dbcore.DB); err != nil {
log.Fatalf("Failed to run database migrations: %s", err)
}
log.Debug("Initializing the JWT subsystem...")
if err := jwtcore.SetupJWT(); err != nil {
log.Fatalf("Failed to initialize the JWT subsystem: %s", err.Error())
}
log.Debug("Initializing API...")
if !developmentMode {
gin.SetMode(gin.ReleaseMode)
}
engine := gin.Default()
listeningAddress := os.Getenv("HERMES_LISTENING_ADDRESS")
if listeningAddress == "" {
if developmentMode {
listeningAddress = "localhost:8000"
} else {
listeningAddress = "0.0.0.0:8000"
}
}
trustedProxiesString := os.Getenv("HERMES_TRUSTED_HTTP_PROXIES")
if trustedProxiesString != "" {
trustedProxies := strings.Split(trustedProxiesString, ",")
engine.ForwardedByClientIP = true
engine.SetTrustedProxies(trustedProxies)
} else {
engine.ForwardedByClientIP = false
engine.SetTrustedProxies(nil)
}
// Initialize routes
engine.POST("/api/v1/users/create", users.CreateUser)
log.Infof("Listening on: %s", listeningAddress)
err = engine.Run(listeningAddress)
if err != nil {
log.Fatalf("Error running web server: %s", err.Error())
}
}