chore: Adds user creation route.
This commit is contained in:
parent
c7b71e754f
commit
1237a31f8b
29 changed files with 630 additions and 63 deletions
26
backend/api/constants/permission_nodes.go
Normal file
26
backend/api/constants/permission_nodes.go
Normal 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",
|
||||
}
|
176
backend/api/controllers/v1/users/create.go
Normal file
176
backend/api/controllers/v1/users/create.go
Normal 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
133
backend/api/dbcore/db.go
Normal 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
|
||||
}
|
59
backend/api/jwtcore/jwt.go
Normal file
59
backend/api/jwtcore/jwt.go
Normal 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
102
backend/api/main.go
Normal 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())
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue