mirror of
https://github.com/nikdoof/pocket-id.git
synced 2025-12-14 07:12:19 +00:00
initial commit
This commit is contained in:
78
backend/internal/bootstrap/bootstrap.go
Normal file
78
backend/internal/bootstrap/bootstrap.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
_ "github.com/golang-migrate/migrate/v4/source/file"
|
||||
"golang-rest-api-template/internal/common"
|
||||
"golang-rest-api-template/internal/common/middleware"
|
||||
"golang-rest-api-template/internal/handler"
|
||||
"golang-rest-api-template/internal/job"
|
||||
"golang-rest-api-template/internal/utils"
|
||||
"golang.org/x/time/rate"
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
func Bootstrap() {
|
||||
common.InitDatabase()
|
||||
common.InitDbConfig()
|
||||
initApplicationImages()
|
||||
job.RegisterJobs()
|
||||
initRouter()
|
||||
}
|
||||
|
||||
func initRouter() {
|
||||
switch common.EnvConfig.AppEnv {
|
||||
case "production":
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
case "development":
|
||||
gin.SetMode(gin.DebugMode)
|
||||
case "test":
|
||||
gin.SetMode(gin.TestMode)
|
||||
}
|
||||
|
||||
r := gin.Default()
|
||||
|
||||
r.Use(gin.Logger())
|
||||
|
||||
r.Use(middleware.Cors())
|
||||
r.Use(middleware.RateLimiter(rate.Every(time.Second), 60))
|
||||
|
||||
apiGroup := r.Group("/api")
|
||||
handler.RegisterRoutes(apiGroup)
|
||||
handler.RegisterOIDCRoutes(apiGroup)
|
||||
handler.RegisterUserRoutes(apiGroup)
|
||||
handler.RegisterConfigurationRoutes(apiGroup)
|
||||
if common.EnvConfig.AppEnv != "production" {
|
||||
handler.RegisterTestRoutes(apiGroup)
|
||||
}
|
||||
|
||||
baseGroup := r.Group("/")
|
||||
handler.RegisterWellKnownRoutes(baseGroup)
|
||||
|
||||
if err := r.Run(common.EnvConfig.Host + ":" + common.EnvConfig.Port); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func initApplicationImages() {
|
||||
dirPath := common.EnvConfig.UploadPath + "/application-images"
|
||||
|
||||
files, err := os.ReadDir(dirPath)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
log.Fatalf("Error reading directory: %v", err)
|
||||
}
|
||||
|
||||
// Skip if files already exist
|
||||
if len(files) > 1 {
|
||||
return
|
||||
}
|
||||
|
||||
// Copy files from source to destination
|
||||
err = utils.CopyDirectory("./images", dirPath)
|
||||
if err != nil {
|
||||
log.Fatalf("Error copying directory: %v", err)
|
||||
}
|
||||
}
|
||||
133
backend/internal/common/config.go
Normal file
133
backend/internal/common/config.go
Normal file
@@ -0,0 +1,133 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"github.com/caarlos0/env/v11"
|
||||
_ "github.com/joho/godotenv/autoload"
|
||||
"golang-rest-api-template/internal/model"
|
||||
"log"
|
||||
"reflect"
|
||||
)
|
||||
|
||||
type EnvConfigSchema struct {
|
||||
AppEnv string `env:"APP_ENV"`
|
||||
AppURL string `env:"PUBLIC_APP_URL"`
|
||||
DBPath string `env:"DB_PATH"`
|
||||
UploadPath string `env:"UPLOAD_PATH"`
|
||||
Port string `env:"BACKEND_PORT"`
|
||||
Host string `env:"HOST"`
|
||||
}
|
||||
|
||||
var EnvConfig = &EnvConfigSchema{
|
||||
AppEnv: "production",
|
||||
DBPath: "data/pocket-id.db",
|
||||
UploadPath: "data/uploads",
|
||||
AppURL: "http://localhost",
|
||||
Port: "8080",
|
||||
Host: "localhost",
|
||||
}
|
||||
|
||||
var DbConfig = NewDefaultDbConfig()
|
||||
|
||||
func NewDefaultDbConfig() model.ApplicationConfiguration {
|
||||
return model.ApplicationConfiguration{
|
||||
AppName: model.ApplicationConfigurationVariable{
|
||||
Key: "appName",
|
||||
Type: "string",
|
||||
IsPublic: true,
|
||||
Value: "Pocket ID",
|
||||
},
|
||||
BackgroundImageType: model.ApplicationConfigurationVariable{
|
||||
Key: "backgroundImageType",
|
||||
Type: "string",
|
||||
IsInternal: true,
|
||||
Value: "jpg",
|
||||
},
|
||||
LogoImageType: model.ApplicationConfigurationVariable{
|
||||
Key: "logoImageType",
|
||||
Type: "string",
|
||||
IsInternal: true,
|
||||
Value: "svg",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// LoadDbConfigFromDb refreshes the database configuration by loading the current values
|
||||
// from the database and updating the DbConfig struct.
|
||||
func LoadDbConfigFromDb() error {
|
||||
dbConfigReflectValue := reflect.ValueOf(&DbConfig).Elem()
|
||||
|
||||
for i := 0; i < dbConfigReflectValue.NumField(); i++ {
|
||||
dbConfigField := dbConfigReflectValue.Field(i)
|
||||
currentConfigVar := dbConfigField.Interface().(model.ApplicationConfigurationVariable)
|
||||
var storedConfigVar model.ApplicationConfigurationVariable
|
||||
if err := DB.First(&storedConfigVar, "key = ?", currentConfigVar.Key).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dbConfigField.Set(reflect.ValueOf(storedConfigVar))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// InitDbConfig creates the default configuration values in the database if they do not exist,
|
||||
// updates existing configurations if they differ from the default, and deletes any configurations
|
||||
// that are not in the default configuration.
|
||||
func InitDbConfig() {
|
||||
// Reflect to get the underlying value of DbConfig and its default configuration
|
||||
dbConfigReflectValue := reflect.ValueOf(&DbConfig).Elem()
|
||||
defaultDbConfig := NewDefaultDbConfig()
|
||||
defaultConfigReflectValue := reflect.ValueOf(&defaultDbConfig).Elem()
|
||||
defaultKeys := make(map[string]struct{})
|
||||
|
||||
// Iterate over the fields of DbConfig
|
||||
for i := 0; i < dbConfigReflectValue.NumField(); i++ {
|
||||
dbConfigField := dbConfigReflectValue.Field(i)
|
||||
currentConfigVar := dbConfigField.Interface().(model.ApplicationConfigurationVariable)
|
||||
defaultConfigVar := defaultConfigReflectValue.Field(i).Interface().(model.ApplicationConfigurationVariable)
|
||||
defaultKeys[currentConfigVar.Key] = struct{}{}
|
||||
|
||||
var storedConfigVar model.ApplicationConfigurationVariable
|
||||
if err := DB.First(&storedConfigVar, "key = ?", currentConfigVar.Key).Error; err != nil {
|
||||
// If the configuration does not exist, create it
|
||||
if err := DB.Create(&defaultConfigVar).Error; err != nil {
|
||||
log.Fatalf("Failed to create default configuration: %v", err)
|
||||
}
|
||||
dbConfigField.Set(reflect.ValueOf(defaultConfigVar))
|
||||
continue
|
||||
}
|
||||
|
||||
// Update existing configuration if it differs from the default
|
||||
if storedConfigVar.Type != defaultConfigVar.Type || storedConfigVar.IsPublic != defaultConfigVar.IsPublic || storedConfigVar.IsInternal != defaultConfigVar.IsInternal {
|
||||
storedConfigVar.Type = defaultConfigVar.Type
|
||||
storedConfigVar.IsPublic = defaultConfigVar.IsPublic
|
||||
storedConfigVar.IsInternal = defaultConfigVar.IsInternal
|
||||
if err := DB.Save(&storedConfigVar).Error; err != nil {
|
||||
log.Fatalf("Failed to update configuration: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Set the value in DbConfig
|
||||
dbConfigField.Set(reflect.ValueOf(storedConfigVar))
|
||||
}
|
||||
|
||||
// Delete any configurations not in the default keys
|
||||
var allConfigVars []model.ApplicationConfigurationVariable
|
||||
if err := DB.Find(&allConfigVars).Error; err != nil {
|
||||
log.Fatalf("Failed to retrieve existing configurations: %v", err)
|
||||
}
|
||||
|
||||
for _, config := range allConfigVars {
|
||||
if _, exists := defaultKeys[config.Key]; !exists {
|
||||
if err := DB.Delete(&config).Error; err != nil {
|
||||
log.Fatalf("Failed to delete outdated configuration: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
if err := env.ParseWithOptions(EnvConfig, env.Options{}); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
84
backend/internal/common/db.go
Normal file
84
backend/internal/common/db.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
"github.com/golang-migrate/migrate/v4/database/sqlite3"
|
||||
"gorm.io/gorm/logger"
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var DB *gorm.DB
|
||||
|
||||
func InitDatabase() {
|
||||
connectDatabase()
|
||||
sqlDb, err := DB.DB()
|
||||
if err != nil {
|
||||
log.Fatal("failed to get sql db", err)
|
||||
}
|
||||
driver, err := sqlite3.WithInstance(sqlDb, &sqlite3.Config{})
|
||||
m, err := migrate.NewWithDatabaseInstance(
|
||||
"file://migrations",
|
||||
"postgres", driver)
|
||||
if err != nil {
|
||||
log.Fatal("failed to create migration instance", err)
|
||||
}
|
||||
|
||||
err = m.Up()
|
||||
if err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
||||
log.Fatal("failed to run migrations", err)
|
||||
}
|
||||
}
|
||||
|
||||
func connectDatabase() {
|
||||
var database *gorm.DB
|
||||
var err error
|
||||
|
||||
dbPath := EnvConfig.DBPath
|
||||
if EnvConfig.AppEnv == "test" {
|
||||
dbPath = "file::memory:?cache=shared"
|
||||
}
|
||||
|
||||
for i := 1; i <= 3; i++ {
|
||||
database, err = gorm.Open(sqlite.Open(dbPath), &gorm.Config{
|
||||
TranslateError: true,
|
||||
Logger: getLogger(),
|
||||
})
|
||||
if err == nil {
|
||||
break
|
||||
} else {
|
||||
log.Printf("Attempt %d: Failed to initialize database. Retrying...", i)
|
||||
time.Sleep(3 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
DB = database
|
||||
}
|
||||
|
||||
func getLogger() logger.Interface {
|
||||
isProduction := EnvConfig.AppEnv == "production"
|
||||
|
||||
var logLevel logger.LogLevel
|
||||
if isProduction {
|
||||
logLevel = logger.Error
|
||||
} else {
|
||||
logLevel = logger.Info
|
||||
}
|
||||
|
||||
// Create the GORM logger
|
||||
return logger.New(
|
||||
log.New(os.Stdout, "\r\n", log.LstdFlags),
|
||||
logger.Config{
|
||||
SlowThreshold: 200 * time.Millisecond,
|
||||
LogLevel: logLevel,
|
||||
IgnoreRecordNotFoundError: isProduction,
|
||||
ParameterizedQueries: isProduction,
|
||||
Colorful: !isProduction,
|
||||
},
|
||||
)
|
||||
}
|
||||
207
backend/internal/common/jwt.go
Normal file
207
backend/internal/common/jwt.go
Normal file
@@ -0,0 +1,207 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"golang-rest-api-template/internal/model"
|
||||
"golang-rest-api-template/internal/utils"
|
||||
"log"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
PrivateKey *rsa.PrivateKey
|
||||
PublicKey *rsa.PublicKey
|
||||
)
|
||||
|
||||
const (
|
||||
privateKeyPath = "data/keys/jwt_private_key.pem"
|
||||
publicKeyPath = "data/keys/jwt_public_key.pem"
|
||||
)
|
||||
|
||||
type accessTokenJWTClaims struct {
|
||||
jwt.RegisteredClaims
|
||||
IsAdmin bool `json:"isAdmin,omitempty"`
|
||||
}
|
||||
|
||||
// GenerateIDToken generates an ID token for the given user, clientID, scope and nonce.
|
||||
func GenerateIDToken(user model.User, clientID string, scope string, nonce string) (tokenString string, err error) {
|
||||
profileClaims := map[string]interface{}{
|
||||
"given_name": user.FirstName,
|
||||
"family_name": user.LastName,
|
||||
"email": user.Email,
|
||||
"preferred_username": user.Username,
|
||||
}
|
||||
|
||||
claims := jwt.MapClaims{
|
||||
"sub": user.ID,
|
||||
"aud": clientID,
|
||||
"exp": jwt.NewNumericDate(time.Now().Add(1 * time.Hour)),
|
||||
"iat": jwt.NewNumericDate(time.Now()),
|
||||
}
|
||||
|
||||
if nonce != "" {
|
||||
claims["nonce"] = nonce
|
||||
}
|
||||
if strings.Contains(scope, "profile") {
|
||||
for k, v := range profileClaims {
|
||||
claims[k] = v
|
||||
}
|
||||
}
|
||||
if strings.Contains(scope, "email") {
|
||||
claims["email"] = user.Email
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||
signedToken, err := token.SignedString(PrivateKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return signedToken, nil
|
||||
}
|
||||
|
||||
// GenerateAccessToken generates an access token for the given user.
|
||||
func GenerateAccessToken(user model.User) (tokenString string, err error) {
|
||||
claim := accessTokenJWTClaims{
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Subject: user.ID,
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
Audience: jwt.ClaimStrings{utils.GetHostFromURL(EnvConfig.AppURL)},
|
||||
},
|
||||
IsAdmin: user.IsAdmin,
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claim)
|
||||
tokenString, err = token.SignedString(PrivateKey)
|
||||
return tokenString, err
|
||||
}
|
||||
|
||||
// VerifyAccessToken verifies the given access token and returns the claims if the token is valid.
|
||||
func VerifyAccessToken(tokenString string) (*accessTokenJWTClaims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenString, &accessTokenJWTClaims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
return PublicKey, nil
|
||||
})
|
||||
if err != nil || !token.Valid {
|
||||
return nil, errors.New("couldn't handle this token")
|
||||
}
|
||||
|
||||
claims, isValid := token.Claims.(*accessTokenJWTClaims)
|
||||
if !isValid {
|
||||
return nil, errors.New("can't parse claims")
|
||||
}
|
||||
|
||||
if !slices.Contains(claims.Audience, utils.GetHostFromURL(EnvConfig.AppURL)) {
|
||||
return nil, errors.New("audience doesn't match")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
type JWK struct {
|
||||
Kty string `json:"kty"`
|
||||
Use string `json:"use"`
|
||||
Kid string `json:"kid"`
|
||||
Alg string `json:"alg"`
|
||||
N string `json:"n"`
|
||||
E string `json:"e"`
|
||||
}
|
||||
|
||||
// GetJWK returns the JSON Web Key (JWK) for the public key.
|
||||
func GetJWK() (JWK, error) {
|
||||
if PublicKey == nil {
|
||||
return JWK{}, errors.New("public key is not initialized")
|
||||
}
|
||||
|
||||
// Create JWK from RSA public key
|
||||
jwk := JWK{
|
||||
Kty: "RSA",
|
||||
Use: "sig",
|
||||
Kid: "1", // Key ID can be set to any identifier. Here it's statically set to "1"
|
||||
Alg: "RS256",
|
||||
N: base64.RawURLEncoding.EncodeToString(PublicKey.N.Bytes()),
|
||||
E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(PublicKey.E)).Bytes()),
|
||||
}
|
||||
|
||||
return jwk, nil
|
||||
}
|
||||
|
||||
// generateKeys generates a new RSA key pair and saves the private and public keys to the data folder.
|
||||
func generateKeys() {
|
||||
if err := os.MkdirAll(filepath.Dir(privateKeyPath), 0700); err != nil {
|
||||
log.Fatal("Failed to create directories for keys", err)
|
||||
}
|
||||
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
log.Fatal("Failed to generate private key", err)
|
||||
}
|
||||
|
||||
privateKeyFile, err := os.Create(privateKeyPath)
|
||||
if err != nil {
|
||||
log.Fatal("Failed to create private key file", err)
|
||||
}
|
||||
defer privateKeyFile.Close()
|
||||
|
||||
privateKeyPEM := pem.EncodeToMemory(
|
||||
&pem.Block{
|
||||
Type: "RSA PRIVATE KEY",
|
||||
Bytes: x509.MarshalPKCS1PrivateKey(privateKey),
|
||||
},
|
||||
)
|
||||
_, err = privateKeyFile.Write(privateKeyPEM)
|
||||
if err != nil {
|
||||
log.Fatal("Failed to write private key file", err)
|
||||
}
|
||||
|
||||
publicKey := &privateKey.PublicKey
|
||||
publicKeyFile, err := os.Create(publicKeyPath)
|
||||
if err != nil {
|
||||
log.Fatal("Failed to create public key file", err)
|
||||
}
|
||||
defer publicKeyFile.Close()
|
||||
|
||||
publicKeyPEM := pem.EncodeToMemory(
|
||||
&pem.Block{
|
||||
Type: "RSA PUBLIC KEY",
|
||||
Bytes: x509.MarshalPKCS1PublicKey(publicKey),
|
||||
},
|
||||
)
|
||||
_, err = publicKeyFile.Write(publicKeyPEM)
|
||||
if err != nil {
|
||||
log.Fatal("Failed to write public key file", err)
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
if _, err := os.Stat(privateKeyPath); os.IsNotExist(err) {
|
||||
generateKeys()
|
||||
}
|
||||
|
||||
privateKeyBytes, err := os.ReadFile(privateKeyPath)
|
||||
if err != nil {
|
||||
log.Fatal("Can't read jwt private key", err)
|
||||
}
|
||||
PrivateKey, err = jwt.ParseRSAPrivateKeyFromPEM(privateKeyBytes)
|
||||
if err != nil {
|
||||
log.Fatal("Can't parse jwt private key", err)
|
||||
}
|
||||
|
||||
publicKeyBytes, err := os.ReadFile(publicKeyPath)
|
||||
if err != nil {
|
||||
log.Fatal("Can't read jwt public key", err)
|
||||
}
|
||||
PublicKey, err = jwt.ParseRSAPublicKeyFromPEM(publicKeyBytes)
|
||||
if err != nil {
|
||||
log.Fatal("Can't parse jwt public key", err)
|
||||
}
|
||||
}
|
||||
18
backend/internal/common/middleware/cors.go
Normal file
18
backend/internal/common/middleware/cors.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"golang-rest-api-template/internal/common"
|
||||
"time"
|
||||
|
||||
"github.com/gin-contrib/cors"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func Cors() gin.HandlerFunc {
|
||||
return cors.New(cors.Config{
|
||||
AllowOrigins: []string{common.EnvConfig.AppURL},
|
||||
AllowMethods: []string{"*"},
|
||||
AllowHeaders: []string{"*"},
|
||||
MaxAge: 12 * time.Hour,
|
||||
})
|
||||
}
|
||||
40
backend/internal/common/middleware/file_size_limit.go
Normal file
40
backend/internal/common/middleware/file_size_limit.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang-rest-api-template/internal/utils"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func LimitFileSize(maxSize int64) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxSize)
|
||||
if err := c.Request.ParseMultipartForm(maxSize); err != nil {
|
||||
utils.HandlerError(c, http.StatusRequestEntityTooLarge, fmt.Sprintf("The file can't be larger than %s bytes", formatFileSize(maxSize)))
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// formatFileSize formats a file size in bytes to a human-readable string
|
||||
func formatFileSize(size int64) string {
|
||||
const (
|
||||
KB = 1 << (10 * 1)
|
||||
MB = 1 << (10 * 2)
|
||||
GB = 1 << (10 * 3)
|
||||
)
|
||||
|
||||
switch {
|
||||
case size >= GB:
|
||||
return fmt.Sprintf("%.2f GB", float64(size)/GB)
|
||||
case size >= MB:
|
||||
return fmt.Sprintf("%.2f MB", float64(size)/MB)
|
||||
case size >= KB:
|
||||
return fmt.Sprintf("%.2f KB", float64(size)/KB)
|
||||
default:
|
||||
return fmt.Sprintf("%d bytes", size)
|
||||
}
|
||||
}
|
||||
47
backend/internal/common/middleware/jwt_auth.go
Normal file
47
backend/internal/common/middleware/jwt_auth.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang-rest-api-template/internal/common"
|
||||
"golang-rest-api-template/internal/utils"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func JWTAuth(adminOnly bool) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
|
||||
// Extract the token from the cookie or the Authorization header
|
||||
token, err := c.Cookie("access_token")
|
||||
if err != nil {
|
||||
authorizationHeaderSplitted := strings.Split(c.GetHeader("Authorization"), " ")
|
||||
if len(authorizationHeaderSplitted) == 2 {
|
||||
token = authorizationHeaderSplitted[1]
|
||||
} else {
|
||||
utils.HandlerError(c, http.StatusUnauthorized, "You're not signed in")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Verify the token
|
||||
claims, err := common.VerifyAccessToken(token)
|
||||
if err != nil {
|
||||
utils.HandlerError(c, http.StatusUnauthorized, "You're not signed in")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the user is an admin
|
||||
if adminOnly && !claims.IsAdmin {
|
||||
utils.HandlerError(c, http.StatusForbidden, "You don't have permission to access this resource")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("userID", claims.Subject)
|
||||
c.Set("userIsAdmin", claims.IsAdmin)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
76
backend/internal/common/middleware/rate_limit.go
Normal file
76
backend/internal/common/middleware/rate_limit.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"golang-rest-api-template/internal/common"
|
||||
"golang-rest-api-template/internal/utils"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
// RateLimiter is a Gin middleware for rate limiting based on client IP
|
||||
func RateLimiter(limit rate.Limit, burst int) gin.HandlerFunc {
|
||||
// Start the cleanup routine
|
||||
go cleanupClients()
|
||||
|
||||
return func(c *gin.Context) {
|
||||
ip := c.ClientIP()
|
||||
|
||||
// Skip rate limiting for localhost and test environment
|
||||
// If the client ip is localhost the request comes from the frontend
|
||||
if ip == "127.0.0.1" || ip == "::1" || common.EnvConfig.AppEnv == "test" {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
limiter := getLimiter(ip, limit, burst)
|
||||
if !limiter.Allow() {
|
||||
utils.HandlerError(c, http.StatusTooManyRequests, "Too many requests. Please wait a while before trying again.")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
type client struct {
|
||||
limiter *rate.Limiter
|
||||
lastSeen time.Time
|
||||
}
|
||||
|
||||
// Map to store the rate limiters per IP
|
||||
var clients = make(map[string]*client)
|
||||
var mu sync.Mutex
|
||||
|
||||
// Cleanup routine to remove stale clients that haven't been seen for a while
|
||||
func cleanupClients() {
|
||||
for {
|
||||
time.Sleep(time.Minute)
|
||||
mu.Lock()
|
||||
for ip, client := range clients {
|
||||
if time.Since(client.lastSeen) > 3*time.Minute {
|
||||
delete(clients, ip)
|
||||
}
|
||||
}
|
||||
mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// getLimiter retrieves the rate limiter for a given IP address, creating one if it doesn't exist
|
||||
func getLimiter(ip string, limit rate.Limit, burst int) *rate.Limiter {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
if client, exists := clients[ip]; exists {
|
||||
client.lastSeen = time.Now()
|
||||
return client.limiter
|
||||
}
|
||||
|
||||
limiter := rate.NewLimiter(limit, burst)
|
||||
clients[ip] = &client{limiter: limiter, lastSeen: time.Now()}
|
||||
return limiter
|
||||
}
|
||||
37
backend/internal/common/webauthn.go
Normal file
37
backend/internal/common/webauthn.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
"golang-rest-api-template/internal/utils"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
WebAuthn *webauthn.WebAuthn
|
||||
err error
|
||||
)
|
||||
|
||||
func init() {
|
||||
config := &webauthn.Config{
|
||||
RPDisplayName: DbConfig.AppName.Value,
|
||||
RPID: utils.GetHostFromURL(EnvConfig.AppURL),
|
||||
RPOrigins: []string{EnvConfig.AppURL},
|
||||
Timeouts: webauthn.TimeoutsConfig{
|
||||
Login: webauthn.TimeoutConfig{
|
||||
Enforce: true,
|
||||
Timeout: time.Second * 60,
|
||||
TimeoutUVD: time.Second * 60,
|
||||
},
|
||||
Registration: webauthn.TimeoutConfig{
|
||||
Enforce: true,
|
||||
Timeout: time.Second * 60,
|
||||
TimeoutUVD: time.Second * 60,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if WebAuthn, err = webauthn.New(config); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
190
backend/internal/handler/application_configuration.go
Normal file
190
backend/internal/handler/application_configuration.go
Normal file
@@ -0,0 +1,190 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang-rest-api-template/internal/common"
|
||||
"golang-rest-api-template/internal/common/middleware"
|
||||
"golang-rest-api-template/internal/model"
|
||||
"golang-rest-api-template/internal/utils"
|
||||
"gorm.io/gorm"
|
||||
"net/http"
|
||||
"os"
|
||||
"reflect"
|
||||
)
|
||||
|
||||
func RegisterConfigurationRoutes(group *gin.RouterGroup) {
|
||||
group.GET("/application-configuration", listApplicationConfigurationHandler)
|
||||
group.PUT("/application-configuration", updateApplicationConfigurationHandler)
|
||||
|
||||
group.GET("/application-configuration/logo", getLogoHandler)
|
||||
group.GET("/application-configuration/background-image", getBackgroundImageHandler)
|
||||
group.GET("/application-configuration/favicon", getFaviconHandler)
|
||||
group.PUT("/application-configuration/logo", middleware.JWTAuth(true), updateLogoHandler)
|
||||
group.PUT("/application-configuration/favicon", middleware.JWTAuth(true), updateFaviconHandler)
|
||||
group.PUT("/application-configuration/background-image", middleware.JWTAuth(true), updateBackgroundImageHandler)
|
||||
}
|
||||
|
||||
func listApplicationConfigurationHandler(c *gin.Context) {
|
||||
// Return also the private configuration variables if the user is admin and showAll is true
|
||||
showAll := c.GetBool("userIsAdmin") && c.DefaultQuery("showAll", "false") == "true"
|
||||
|
||||
var configuration []model.ApplicationConfigurationVariable
|
||||
var err error
|
||||
|
||||
if showAll {
|
||||
err = common.DB.Find(&configuration).Error
|
||||
} else {
|
||||
err = common.DB.Find(&configuration, "is_public = true").Error
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, configuration)
|
||||
}
|
||||
|
||||
func updateApplicationConfigurationHandler(c *gin.Context) {
|
||||
var input model.ApplicationConfigurationUpdateDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
utils.HandlerError(c, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
savedConfigVariables := make([]model.ApplicationConfigurationVariable, 10)
|
||||
|
||||
tx := common.DB.Begin()
|
||||
rt := reflect.ValueOf(input).Type()
|
||||
rv := reflect.ValueOf(input)
|
||||
|
||||
// Loop over the input struct fields and update the related configuration variables
|
||||
for i := 0; i < rt.NumField(); i++ {
|
||||
field := rt.Field(i)
|
||||
key := field.Tag.Get("json")
|
||||
value := rv.FieldByName(field.Name).String()
|
||||
|
||||
// Get the existing configuration variable from the db
|
||||
var applicationConfigurationVariable model.ApplicationConfigurationVariable
|
||||
if err := tx.First(&applicationConfigurationVariable, "key = ? AND is_internal = false", key).Error; err != nil {
|
||||
tx.Rollback()
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
utils.HandlerError(c, http.StatusNotFound, fmt.Sprintf("Invalid configuration variable '%s'", value))
|
||||
} else {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Update the value of the existing configuration variable and save it
|
||||
applicationConfigurationVariable.Value = value
|
||||
if err := tx.Save(&applicationConfigurationVariable).Error; err != nil {
|
||||
tx.Rollback()
|
||||
utils.UnknownHandlerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
savedConfigVariables[i] = applicationConfigurationVariable
|
||||
}
|
||||
|
||||
tx.Commit()
|
||||
|
||||
if err := common.LoadDbConfigFromDb(); err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, savedConfigVariables)
|
||||
|
||||
}
|
||||
|
||||
func getLogoHandler(c *gin.Context) {
|
||||
imagType := common.DbConfig.LogoImageType.Value
|
||||
getImage(c, "logo", imagType)
|
||||
}
|
||||
|
||||
func getFaviconHandler(c *gin.Context) {
|
||||
getImage(c, "favicon", "ico")
|
||||
}
|
||||
|
||||
func getBackgroundImageHandler(c *gin.Context) {
|
||||
imageType := common.DbConfig.BackgroundImageType.Value
|
||||
getImage(c, "background", imageType)
|
||||
}
|
||||
|
||||
func updateLogoHandler(c *gin.Context) {
|
||||
imageType := common.DbConfig.LogoImageType.Value
|
||||
updateImage(c, "logo", imageType)
|
||||
}
|
||||
|
||||
func updateFaviconHandler(c *gin.Context) {
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
utils.HandlerError(c, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
fileType := utils.GetFileExtension(file.Filename)
|
||||
if fileType != "ico" {
|
||||
utils.HandlerError(c, http.StatusBadRequest, "File must be of type .ico")
|
||||
return
|
||||
}
|
||||
updateImage(c, "favicon", "ico")
|
||||
}
|
||||
|
||||
func updateBackgroundImageHandler(c *gin.Context) {
|
||||
imagType := common.DbConfig.BackgroundImageType.Value
|
||||
updateImage(c, "background", imagType)
|
||||
}
|
||||
|
||||
func getImage(c *gin.Context, name string, imageType string) {
|
||||
imagePath := fmt.Sprintf("%s/application-images/%s.%s", common.EnvConfig.UploadPath, name, imageType)
|
||||
mimeType := utils.GetImageMimeType(imageType)
|
||||
|
||||
c.Header("Content-Type", mimeType)
|
||||
c.File(imagePath)
|
||||
}
|
||||
|
||||
func updateImage(c *gin.Context, imageName string, oldImageType string) {
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
utils.HandlerError(c, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
fileType := utils.GetFileExtension(file.Filename)
|
||||
if mimeType := utils.GetImageMimeType(fileType); mimeType == "" {
|
||||
utils.HandlerError(c, http.StatusBadRequest, "File type not supported")
|
||||
return
|
||||
}
|
||||
|
||||
// Delete the old image if it has a different file type
|
||||
if fileType != oldImageType {
|
||||
oldImagePath := fmt.Sprintf("%s/application-images/%s.%s", common.EnvConfig.UploadPath, imageName, oldImageType)
|
||||
if err := os.Remove(oldImagePath); err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
imagePath := fmt.Sprintf("%s/application-images/%s.%s", common.EnvConfig.UploadPath, imageName, fileType)
|
||||
err = c.SaveUploadedFile(file, imagePath)
|
||||
if err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Update the file type in the database
|
||||
key := fmt.Sprintf("%sImageType", imageName)
|
||||
err = common.DB.Model(&model.ApplicationConfigurationVariable{}).Where("key = ?", key).Update("value", fileType).Error
|
||||
if err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := common.LoadDbConfigFromDb(); err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
415
backend/internal/handler/oidc.go
Normal file
415
backend/internal/handler/oidc.go
Normal file
@@ -0,0 +1,415 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang-rest-api-template/internal/common"
|
||||
"golang-rest-api-template/internal/common/middleware"
|
||||
"golang-rest-api-template/internal/model"
|
||||
"golang-rest-api-template/internal/utils"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
func RegisterOIDCRoutes(group *gin.RouterGroup) {
|
||||
group.POST("/oidc/authorize", middleware.JWTAuth(false), authorizeHandler)
|
||||
group.POST("/oidc/authorize/new-client", middleware.JWTAuth(false), authorizeNewClientHandler)
|
||||
group.POST("/oidc/token", createIDTokenHandler)
|
||||
|
||||
group.GET("/oidc/clients", middleware.JWTAuth(true), listClientsHandler)
|
||||
group.POST("/oidc/clients", middleware.JWTAuth(true), createClientHandler)
|
||||
group.GET("/oidc/clients/:id", getClientHandler)
|
||||
group.PUT("/oidc/clients/:id", middleware.JWTAuth(true), updateClientHandler)
|
||||
group.DELETE("/oidc/clients/:id", middleware.JWTAuth(true), deleteClientHandler)
|
||||
|
||||
group.POST("/oidc/clients/:id/secret", middleware.JWTAuth(true), createClientSecretHandler)
|
||||
|
||||
group.GET("/oidc/clients/:id/logo", getClientLogoHandler)
|
||||
group.DELETE("/oidc/clients/:id/logo", deleteClientLogoHandler)
|
||||
group.POST("/oidc/clients/:id/logo", middleware.JWTAuth(true), middleware.LimitFileSize(2<<20), updateClientLogoHandler)
|
||||
}
|
||||
|
||||
type AuthorizeRequest struct {
|
||||
ClientID string `json:"clientID" binding:"required"`
|
||||
Scope string `json:"scope" binding:"required"`
|
||||
Nonce string `json:"nonce"`
|
||||
}
|
||||
|
||||
func authorizeHandler(c *gin.Context) {
|
||||
var parsedBody AuthorizeRequest
|
||||
if err := c.ShouldBindJSON(&parsedBody); err != nil {
|
||||
utils.HandlerError(c, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
var userAuthorizedOIDCClient model.UserAuthorizedOidcClient
|
||||
common.DB.First(&userAuthorizedOIDCClient, "client_id = ? AND user_id = ?", parsedBody.ClientID, c.GetString("userID"))
|
||||
|
||||
// If the record isn't found or the scope is different return an error
|
||||
// The client will have to call the authorizeNewClientHandler
|
||||
if userAuthorizedOIDCClient.Scope != parsedBody.Scope {
|
||||
utils.HandlerError(c, http.StatusForbidden, "missing authorization")
|
||||
return
|
||||
}
|
||||
|
||||
authorizationCode, err := createAuthorizationCode(parsedBody.ClientID, c.GetString("userID"), parsedBody.Scope, parsedBody.Nonce)
|
||||
if err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"code": authorizationCode})
|
||||
}
|
||||
|
||||
// authorizeNewClientHandler authorizes a new client for the user
|
||||
// a new client is a new client when the user has not authorized the client before
|
||||
func authorizeNewClientHandler(c *gin.Context) {
|
||||
var parsedBody model.AuthorizeNewClientDto
|
||||
if err := c.ShouldBindJSON(&parsedBody); err != nil {
|
||||
utils.HandlerError(c, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
userAuthorizedClient := model.UserAuthorizedOidcClient{
|
||||
UserID: c.GetString("userID"),
|
||||
ClientID: parsedBody.ClientID,
|
||||
Scope: parsedBody.Scope,
|
||||
}
|
||||
err := common.DB.Create(&userAuthorizedClient).Error
|
||||
|
||||
if err != nil && errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||
err = common.DB.Model(&userAuthorizedClient).Update("scope", parsedBody.Scope).Error
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
authorizationCode, err := createAuthorizationCode(parsedBody.ClientID, c.GetString("userID"), parsedBody.Scope, parsedBody.Nonce)
|
||||
if err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"code": authorizationCode})
|
||||
|
||||
}
|
||||
|
||||
func createIDTokenHandler(c *gin.Context) {
|
||||
var body model.OidcIdTokenDto
|
||||
|
||||
if err := c.ShouldBind(&body); err != nil {
|
||||
utils.HandlerError(c, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
// Currently only authorization_code grant type is supported
|
||||
if body.GrantType != "authorization_code" {
|
||||
utils.HandlerError(c, http.StatusBadRequest, "grant type not supported")
|
||||
return
|
||||
}
|
||||
|
||||
clientID := body.ClientID
|
||||
clientSecret := body.ClientSecret
|
||||
|
||||
// Client id and secret can also be passed over the Authorization header
|
||||
if clientID == "" || clientSecret == "" {
|
||||
var ok bool
|
||||
clientID, clientSecret, ok = c.Request.BasicAuth()
|
||||
if !ok {
|
||||
utils.HandlerError(c, http.StatusBadRequest, "Client id and secret not provided")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Get the client
|
||||
var client model.OidcClient
|
||||
err := common.DB.First(&client, "id = ?", clientID, clientSecret).Error
|
||||
if err != nil {
|
||||
utils.HandlerError(c, http.StatusBadRequest, "OIDC OIDC client not found")
|
||||
return
|
||||
}
|
||||
|
||||
// Check if client secret is correct
|
||||
err = bcrypt.CompareHashAndPassword([]byte(client.Secret), []byte(clientSecret))
|
||||
if err != nil {
|
||||
utils.HandlerError(c, http.StatusBadRequest, "invalid client secret")
|
||||
return
|
||||
}
|
||||
|
||||
var authorizationCodeMetaData model.OidcAuthorizationCode
|
||||
err = common.DB.Preload("User").First(&authorizationCodeMetaData, "code = ?", body.Code).Error
|
||||
if err != nil {
|
||||
utils.HandlerError(c, http.StatusBadRequest, "invalid authorization code")
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the client id matches the client id in the authorization code and if the code has expired
|
||||
if authorizationCodeMetaData.ClientID != clientID && authorizationCodeMetaData.ExpiresAt.Before(time.Now()) {
|
||||
utils.HandlerError(c, http.StatusBadRequest, "invalid authorization code")
|
||||
return
|
||||
}
|
||||
|
||||
idToken, e := common.GenerateIDToken(authorizationCodeMetaData.User, clientID, authorizationCodeMetaData.Scope, authorizationCodeMetaData.Nonce)
|
||||
if e != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Delete the authorization code after it has been used
|
||||
common.DB.Delete(&authorizationCodeMetaData)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"id_token": idToken})
|
||||
}
|
||||
|
||||
func getClientHandler(c *gin.Context) {
|
||||
clientId := c.Param("id")
|
||||
|
||||
var client model.OidcClient
|
||||
err := common.DB.First(&client, "id = ?", clientId).Error
|
||||
if err != nil {
|
||||
utils.HandlerError(c, http.StatusNotFound, "OIDC client not found")
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, client)
|
||||
}
|
||||
|
||||
func listClientsHandler(c *gin.Context) {
|
||||
var clients []model.OidcClient
|
||||
searchTerm := c.Query("search")
|
||||
|
||||
query := common.DB.Model(&model.OidcClient{})
|
||||
|
||||
if searchTerm != "" {
|
||||
searchPattern := "%" + searchTerm + "%"
|
||||
query = query.Where("name LIKE ?", searchPattern)
|
||||
}
|
||||
|
||||
pagination, err := utils.Paginate(c, query, &clients)
|
||||
if err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": clients,
|
||||
"pagination": pagination,
|
||||
})
|
||||
}
|
||||
|
||||
func createClientHandler(c *gin.Context) {
|
||||
var input model.OidcClientCreateDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
utils.HandlerError(c, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
client := model.OidcClient{
|
||||
Name: input.Name,
|
||||
CallbackURL: input.CallbackURL,
|
||||
CreatedByID: c.GetString("userID"),
|
||||
}
|
||||
|
||||
if err := common.DB.Create(&client).Error; err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, client)
|
||||
}
|
||||
|
||||
func deleteClientHandler(c *gin.Context) {
|
||||
var client model.OidcClient
|
||||
if err := common.DB.First(&client, "id = ?", c.Param("id")).Error; err != nil {
|
||||
utils.HandlerError(c, http.StatusNotFound, "OIDC OIDC client not found")
|
||||
return
|
||||
}
|
||||
|
||||
if err := common.DB.Delete(&client).Error; err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func updateClientHandler(c *gin.Context) {
|
||||
var input model.OidcClientCreateDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
utils.HandlerError(c, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
var client model.OidcClient
|
||||
if err := common.DB.First(&client, "id = ?", c.Param("id")).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
utils.HandlerError(c, http.StatusNotFound, "OIDC client not found")
|
||||
return
|
||||
}
|
||||
utils.UnknownHandlerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
client.Name = input.Name
|
||||
client.CallbackURL = input.CallbackURL
|
||||
|
||||
if err := common.DB.Save(&client).Error; err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusNoContent, client)
|
||||
}
|
||||
|
||||
// createClientSecretHandler creates a new secret for the client and revokes the old one
|
||||
func createClientSecretHandler(c *gin.Context) {
|
||||
var client model.OidcClient
|
||||
if err := common.DB.First(&client, "id = ?", c.Param("id")).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
utils.HandlerError(c, http.StatusNotFound, "OIDC client not found")
|
||||
return
|
||||
}
|
||||
utils.UnknownHandlerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
clientSecret, err := utils.GenerateRandomAlphanumericString(32)
|
||||
if err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
hashedSecret, err := bcrypt.GenerateFromPassword([]byte(clientSecret), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
client.Secret = string(hashedSecret)
|
||||
if err := common.DB.Save(&client).Error; err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"secret": clientSecret})
|
||||
}
|
||||
|
||||
func getClientLogoHandler(c *gin.Context) {
|
||||
var client model.OidcClient
|
||||
if err := common.DB.First(&client, "id = ?", c.Param("id")).Error; err != nil {
|
||||
utils.HandlerError(c, http.StatusNotFound, "OIDC client not found")
|
||||
return
|
||||
}
|
||||
|
||||
if client.ImageType == nil {
|
||||
utils.HandlerError(c, http.StatusNotFound, "image not found")
|
||||
return
|
||||
}
|
||||
|
||||
imageType := *client.ImageType
|
||||
|
||||
imagePath := fmt.Sprintf("%s/oidc-client-images/%s.%s", common.EnvConfig.UploadPath, client.ID, imageType)
|
||||
mimeType := utils.GetImageMimeType(imageType)
|
||||
|
||||
c.Header("Content-Type", mimeType)
|
||||
c.File(imagePath)
|
||||
}
|
||||
|
||||
func updateClientLogoHandler(c *gin.Context) {
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
utils.HandlerError(c, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
fileType := utils.GetFileExtension(file.Filename)
|
||||
if mimeType := utils.GetImageMimeType(fileType); mimeType == "" {
|
||||
utils.HandlerError(c, http.StatusBadRequest, "file type not supported")
|
||||
return
|
||||
}
|
||||
|
||||
imagePath := fmt.Sprintf("%s/oidc-client-images/%s.%s", common.EnvConfig.UploadPath, c.Param("id"), fileType)
|
||||
err = c.SaveUploadedFile(file, imagePath)
|
||||
if err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
var client model.OidcClient
|
||||
if err := common.DB.First(&client, "id = ?", c.Param("id")).Error; err != nil {
|
||||
utils.HandlerError(c, http.StatusNotFound, "OIDC client not found")
|
||||
return
|
||||
}
|
||||
|
||||
// Delete the old image if it has a different file type
|
||||
if client.ImageType != nil && fileType != *client.ImageType {
|
||||
oldImagePath := fmt.Sprintf("%s/oidc-client-images/%s.%s", common.EnvConfig.UploadPath, client.ID, *client.ImageType)
|
||||
if err := os.Remove(oldImagePath); err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
client.ImageType = &fileType
|
||||
if err := common.DB.Save(&client).Error; err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func deleteClientLogoHandler(c *gin.Context) {
|
||||
var client model.OidcClient
|
||||
if err := common.DB.First(&client, "id = ?", c.Param("id")).Error; err != nil {
|
||||
utils.HandlerError(c, http.StatusNotFound, "OIDC client not found")
|
||||
return
|
||||
}
|
||||
|
||||
if client.ImageType == nil {
|
||||
utils.HandlerError(c, http.StatusNotFound, "image not found")
|
||||
return
|
||||
}
|
||||
|
||||
imagePath := fmt.Sprintf("%s/oidc-client-images/%s.%s", common.EnvConfig.UploadPath, client.ID, *client.ImageType)
|
||||
if err := os.Remove(imagePath); err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
client.ImageType = nil
|
||||
if err := common.DB.Save(&client).Error; err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func createAuthorizationCode(clientID string, userID string, scope string, nonce string) (string, error) {
|
||||
randomString, err := utils.GenerateRandomAlphanumericString(32)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
oidcAuthorizationCode := model.OidcAuthorizationCode{
|
||||
ExpiresAt: time.Now().Add(15 * time.Minute),
|
||||
Code: randomString,
|
||||
ClientID: clientID,
|
||||
UserID: userID,
|
||||
Scope: scope,
|
||||
Nonce: nonce,
|
||||
}
|
||||
|
||||
if err := common.DB.Create(&oidcAuthorizationCode).Error; err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return randomString, nil
|
||||
}
|
||||
237
backend/internal/handler/test.go
Normal file
237
backend/internal/handler/test.go
Normal file
@@ -0,0 +1,237 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/fxamacker/cbor/v2"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
"golang-rest-api-template/internal/common"
|
||||
"golang-rest-api-template/internal/model"
|
||||
"golang-rest-api-template/internal/utils"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func RegisterTestRoutes(group *gin.RouterGroup) {
|
||||
group.POST("/test/reset", resetAndSeedHandler)
|
||||
}
|
||||
|
||||
func resetAndSeedHandler(c *gin.Context) {
|
||||
if err := resetDatabase(); err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := resetApplicationImages(); err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := seedDatabase(); err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{"message": "Database reset and seeded"})
|
||||
}
|
||||
|
||||
// seedDatabase seeds the database with initial data and uses a transaction to ensure atomicity.
|
||||
func seedDatabase() error {
|
||||
return common.DB.Transaction(func(tx *gorm.DB) error {
|
||||
users := []model.User{
|
||||
{
|
||||
Base: model.Base{
|
||||
ID: "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e",
|
||||
},
|
||||
Username: "tim",
|
||||
Email: "tim.cook@test.com",
|
||||
FirstName: "Tim",
|
||||
LastName: "Cook",
|
||||
IsAdmin: true,
|
||||
},
|
||||
{
|
||||
Base: model.Base{
|
||||
ID: "1cd19686-f9a6-43f4-a41f-14a0bf5b4036",
|
||||
},
|
||||
Username: "craig",
|
||||
Email: "craig.federighi@test.com",
|
||||
FirstName: "Craig",
|
||||
LastName: "Federighi",
|
||||
IsAdmin: false,
|
||||
},
|
||||
}
|
||||
for _, user := range users {
|
||||
if err := tx.Create(&user).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
oidcClients := []model.OidcClient{
|
||||
{
|
||||
Base: model.Base{
|
||||
ID: "3654a746-35d4-4321-ac61-0bdcff2b4055",
|
||||
},
|
||||
Name: "Nextcloud",
|
||||
Secret: "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC", // w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY
|
||||
CallbackURL: "http://nextcloud/auth/callback",
|
||||
ImageType: utils.StringPointer("png"),
|
||||
CreatedByID: users[0].ID,
|
||||
},
|
||||
{
|
||||
Base: model.Base{
|
||||
ID: "606c7782-f2b1-49e5-8ea9-26eb1b06d018",
|
||||
},
|
||||
Name: "Immich",
|
||||
Secret: "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe", // PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x
|
||||
CallbackURL: "http://immich/auth/callback",
|
||||
CreatedByID: users[0].ID,
|
||||
},
|
||||
}
|
||||
for _, client := range oidcClients {
|
||||
if err := tx.Create(&client).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
authCode := model.OidcAuthorizationCode{
|
||||
Code: "auth-code",
|
||||
Scope: "openid profile",
|
||||
Nonce: "nonce",
|
||||
ExpiresAt: time.Now().Add(1 * time.Hour),
|
||||
UserID: users[0].ID,
|
||||
ClientID: oidcClients[0].ID,
|
||||
}
|
||||
if err := tx.Create(&authCode).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
accessToken := model.OneTimeAccessToken{
|
||||
Token: "one-time-token",
|
||||
ExpiresAt: time.Now().Add(1 * time.Hour),
|
||||
UserID: users[0].ID,
|
||||
}
|
||||
if err := tx.Create(&accessToken).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
userAuthorizedClient := model.UserAuthorizedOidcClient{
|
||||
Scope: "openid profile email",
|
||||
UserID: users[0].ID,
|
||||
ClientID: oidcClients[0].ID,
|
||||
}
|
||||
if err := tx.Create(&userAuthorizedClient).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
webauthnCredentials := []model.WebauthnCredential{
|
||||
{
|
||||
Name: "Passkey 1",
|
||||
CredentialID: "test-credential-1",
|
||||
PublicKey: getCborPublicKey("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEwcOo5KV169KR67QEHrcYkeXE3CCxv2BgwnSq4VYTQxyLtdmKxegexa8JdwFKhKXa2BMI9xaN15BoL6wSCRFJhg=="),
|
||||
AttestationType: "none",
|
||||
Transport: model.AuthenticatorTransportList{protocol.Internal},
|
||||
UserID: users[0].ID,
|
||||
},
|
||||
{
|
||||
Name: "Passkey 2",
|
||||
CredentialID: "test-credential-2",
|
||||
PublicKey: getCborPublicKey("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAESq/wR8QbBu3dKnpaw/v0mDxFFDwnJ/L5XHSg2tAmq5x1BpSMmIr3+DxCbybVvGRmWGh8kKhy7SMnK91M6rFHTA=="),
|
||||
AttestationType: "none",
|
||||
Transport: model.AuthenticatorTransportList{protocol.Internal},
|
||||
UserID: users[0].ID,
|
||||
},
|
||||
}
|
||||
for _, credential := range webauthnCredentials {
|
||||
if err := tx.Create(&credential).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
webauthnSession := model.WebauthnSession{
|
||||
Challenge: "challenge",
|
||||
ExpiresAt: time.Now().Add(1 * time.Hour),
|
||||
UserVerification: "preferred",
|
||||
}
|
||||
if err := tx.Create(&webauthnSession).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// resetDatabase resets the database by deleting all rows from each table.
|
||||
func resetDatabase() error {
|
||||
err := common.DB.Transaction(func(tx *gorm.DB) error {
|
||||
var tables []string
|
||||
if err := tx.Raw("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name != 'schema_migrations';").Scan(&tables).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, table := range tables {
|
||||
if err := tx.Exec("DELETE FROM " + table).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
common.InitDbConfig()
|
||||
return nil
|
||||
}
|
||||
|
||||
// resetApplicationImages resets the application images by removing existing images and replacing them with the default ones
|
||||
func resetApplicationImages() error {
|
||||
|
||||
if err := os.RemoveAll(common.EnvConfig.UploadPath); err != nil {
|
||||
log.Printf("Error removing directory: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := utils.CopyDirectory("./images", common.EnvConfig.UploadPath+"/application-images"); err != nil {
|
||||
log.Printf("Error copying directory: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getCborPublicKey decodes a Base64 encoded public key and returns the CBOR encoded COSE key
|
||||
func getCborPublicKey(base64PublicKey string) []byte {
|
||||
decodedKey, err := base64.StdEncoding.DecodeString(base64PublicKey)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to decode base64 key: %v", err)
|
||||
}
|
||||
|
||||
pubKey, err := x509.ParsePKIXPublicKey(decodedKey)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to parse public key: %v", err)
|
||||
}
|
||||
|
||||
ecdsaPubKey, ok := pubKey.(*ecdsa.PublicKey)
|
||||
if !ok {
|
||||
log.Fatalf("Not an ECDSA public key")
|
||||
}
|
||||
|
||||
coseKey := map[int]interface{}{
|
||||
1: 2, // Key type: EC2
|
||||
3: -7, // Algorithm: ECDSA with SHA-256
|
||||
-1: 1, // Curve: P-256
|
||||
-2: ecdsaPubKey.X.Bytes(), // X coordinate
|
||||
-3: ecdsaPubKey.Y.Bytes(), // Y coordinate
|
||||
}
|
||||
|
||||
cborPublicKey, err := cbor.Marshal(coseKey)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to encode CBOR: %v", err)
|
||||
}
|
||||
|
||||
return cborPublicKey
|
||||
}
|
||||
269
backend/internal/handler/user.go
Normal file
269
backend/internal/handler/user.go
Normal file
@@ -0,0 +1,269 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang-rest-api-template/internal/common"
|
||||
"golang-rest-api-template/internal/common/middleware"
|
||||
"golang-rest-api-template/internal/model"
|
||||
"golang-rest-api-template/internal/utils"
|
||||
"golang.org/x/time/rate"
|
||||
"gorm.io/gorm"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
func RegisterUserRoutes(group *gin.RouterGroup) {
|
||||
group.GET("/users", middleware.JWTAuth(true), listUsersHandler)
|
||||
group.GET("/users/me", middleware.JWTAuth(false), getCurrentUserHandler)
|
||||
group.GET("/users/:id", middleware.JWTAuth(true), getUserHandler)
|
||||
group.POST("/users", middleware.JWTAuth(true), createUserHandler)
|
||||
group.PUT("/users/:id", middleware.JWTAuth(true), updateUserHandler)
|
||||
group.PUT("/users/me", middleware.JWTAuth(false), updateCurrentUserHandler)
|
||||
group.DELETE("/users/:id", middleware.JWTAuth(true), deleteUserHandler)
|
||||
|
||||
group.POST("/users/:id/one-time-access-token", middleware.JWTAuth(true), createOneTimeAccessTokenHandler)
|
||||
group.POST("/one-time-access-token/:token", middleware.RateLimiter(rate.Every(10*time.Second), 5), exchangeOneTimeAccessTokenHandler)
|
||||
group.POST("/one-time-access-token/setup", getSetupAccessTokenHandler)
|
||||
}
|
||||
|
||||
func listUsersHandler(c *gin.Context) {
|
||||
var users []model.User
|
||||
searchTerm := c.Query("search")
|
||||
|
||||
query := common.DB.Model(&model.User{})
|
||||
|
||||
if searchTerm != "" {
|
||||
searchPattern := "%" + searchTerm + "%"
|
||||
query = query.Where("email LIKE ? OR first_name LIKE ? OR username LIKE ?", searchPattern, searchPattern, searchPattern)
|
||||
}
|
||||
|
||||
pagination, err := utils.Paginate(c, query, &users)
|
||||
if err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": users,
|
||||
"pagination": pagination,
|
||||
})
|
||||
}
|
||||
|
||||
func getUserHandler(c *gin.Context) {
|
||||
var user model.User
|
||||
if err := common.DB.Where("id = ?", c.Param("id")).First(&user).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
utils.HandlerError(c, http.StatusNotFound, "User not found")
|
||||
return
|
||||
}
|
||||
utils.UnknownHandlerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, user)
|
||||
}
|
||||
|
||||
func getCurrentUserHandler(c *gin.Context) {
|
||||
var user model.User
|
||||
if err := common.DB.Where("id = ?", c.GetString("userID")).First(&user).Error; err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, user)
|
||||
|
||||
}
|
||||
|
||||
func deleteUserHandler(c *gin.Context) {
|
||||
var user model.User
|
||||
if err := common.DB.Where("id = ?", c.Param("id")).First(&user).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
utils.HandlerError(c, http.StatusNotFound, "User not found")
|
||||
return
|
||||
}
|
||||
utils.UnknownHandlerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := common.DB.Delete(&user).Error; err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func createUserHandler(c *gin.Context) {
|
||||
var user model.User
|
||||
if err := c.ShouldBindJSON(&user); err != nil {
|
||||
utils.HandlerError(c, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if err := common.DB.Create(&user).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||
if err := checkDuplicatedFields(user); err != nil {
|
||||
utils.HandlerError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
} else {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, user)
|
||||
}
|
||||
|
||||
func updateUserHandler(c *gin.Context) {
|
||||
updateUser(c, c.Param("id"))
|
||||
}
|
||||
|
||||
func updateCurrentUserHandler(c *gin.Context) {
|
||||
updateUser(c, c.GetString("userID"))
|
||||
}
|
||||
|
||||
func createOneTimeAccessTokenHandler(c *gin.Context) {
|
||||
var input model.OneTimeAccessTokenCreateDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
utils.HandlerError(c, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
randomString, err := utils.GenerateRandomAlphanumericString(16)
|
||||
if err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
oneTimeAccessToken := model.OneTimeAccessToken{
|
||||
UserID: input.UserID,
|
||||
ExpiresAt: input.ExpiresAt,
|
||||
Token: randomString,
|
||||
}
|
||||
|
||||
if err := common.DB.Create(&oneTimeAccessToken).Error; err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"token": oneTimeAccessToken})
|
||||
}
|
||||
|
||||
func exchangeOneTimeAccessTokenHandler(c *gin.Context) {
|
||||
var oneTimeAccessToken model.OneTimeAccessToken
|
||||
if err := common.DB.Where("token = ? AND expires_at > ?", c.Param("token"), utils.FormatDateForDb(time.Now())).Preload("User").First(&oneTimeAccessToken).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
utils.HandlerError(c, http.StatusForbidden, "Token is invalid or expired")
|
||||
return
|
||||
}
|
||||
utils.UnknownHandlerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := common.GenerateAccessToken(oneTimeAccessToken.User)
|
||||
if err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
log.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := common.DB.Delete(&oneTimeAccessToken).Error; err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.SetCookie("access_token", token, int(time.Hour.Seconds()), "/", "", false, true)
|
||||
|
||||
c.JSON(http.StatusOK, oneTimeAccessToken.User)
|
||||
}
|
||||
|
||||
// getSetupAccessTokenHandler creates the initial admin user and returns an access token for the user
|
||||
// This handler is only available if there are no users in the database
|
||||
func getSetupAccessTokenHandler(c *gin.Context) {
|
||||
var userCount int64
|
||||
if err := common.DB.Model(&model.User{}).Count(&userCount).Error; err != nil {
|
||||
log.Fatal("failed to count users", err)
|
||||
}
|
||||
|
||||
// If there are more than one user, we don't need to create the admin user
|
||||
if userCount > 1 {
|
||||
utils.HandlerError(c, http.StatusForbidden, "Setup already completed")
|
||||
return
|
||||
}
|
||||
|
||||
var user = model.User{
|
||||
FirstName: "Admin",
|
||||
LastName: "Admin",
|
||||
Username: "admin",
|
||||
Email: "admin@admin.com",
|
||||
IsAdmin: true,
|
||||
}
|
||||
|
||||
// Create the initial admin user if it doesn't exist
|
||||
if err := common.DB.Model(&model.User{}).Preload("Credentials").FirstOrCreate(&user).Error; err != nil {
|
||||
log.Fatal("failed to create admin user", err)
|
||||
}
|
||||
|
||||
// If the user already has credentials, the setup is already completed
|
||||
if len(user.Credentials) > 0 {
|
||||
utils.HandlerError(c, http.StatusForbidden, "Setup already completed")
|
||||
return
|
||||
}
|
||||
|
||||
token, err := common.GenerateAccessToken(user)
|
||||
if err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
log.Println(err)
|
||||
return
|
||||
}
|
||||
c.SetCookie("access_token", token, int(time.Hour.Seconds()), "/", "", false, true)
|
||||
c.JSON(http.StatusOK, user)
|
||||
}
|
||||
|
||||
func updateUser(c *gin.Context, userID string) {
|
||||
var user model.User
|
||||
if err := common.DB.Where("id = ?", userID).First(&user).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
utils.HandlerError(c, http.StatusNotFound, "User not found")
|
||||
return
|
||||
}
|
||||
utils.UnknownHandlerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
var updatedUser model.User
|
||||
if err := c.ShouldBindJSON(&updatedUser); err != nil {
|
||||
utils.HandlerError(c, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if err := common.DB.Model(&user).Updates(&updatedUser).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||
if err := checkDuplicatedFields(user); err != nil {
|
||||
utils.HandlerError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
} else {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, updatedUser)
|
||||
}
|
||||
|
||||
func checkDuplicatedFields(user model.User) error {
|
||||
var existingUser model.User
|
||||
|
||||
if common.DB.Where("id != ? AND email = ?", user.ID, user.Email).First(&existingUser).Error == nil {
|
||||
return errors.New("email is already taken")
|
||||
}
|
||||
|
||||
if common.DB.Where("id != ? AND username = ?", user.ID, user.Username).First(&existingUser).Error == nil {
|
||||
return errors.New("username is already taken")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
255
backend/internal/handler/webauthn.go
Normal file
255
backend/internal/handler/webauthn.go
Normal file
@@ -0,0 +1,255 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
"golang-rest-api-template/internal/common"
|
||||
"golang-rest-api-template/internal/common/middleware"
|
||||
"golang-rest-api-template/internal/model"
|
||||
"golang-rest-api-template/internal/utils"
|
||||
"golang.org/x/time/rate"
|
||||
"gorm.io/gorm"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func RegisterRoutes(group *gin.RouterGroup) {
|
||||
group.GET("/webauthn/register/start", middleware.JWTAuth(false), beginRegistrationHandler)
|
||||
group.POST("/webauthn/register/finish", middleware.JWTAuth(false), verifyRegistrationHandler)
|
||||
|
||||
group.GET("/webauthn/login/start", beginLoginHandler)
|
||||
group.POST("/webauthn/login/finish", middleware.RateLimiter(rate.Every(10*time.Second), 5), verifyLoginHandler)
|
||||
|
||||
group.POST("/webauthn/logout", middleware.JWTAuth(false), logoutHandler)
|
||||
|
||||
group.GET("/webauthn/credentials", middleware.JWTAuth(false), listCredentialsHandler)
|
||||
group.PATCH("/webauthn/credentials/:id", middleware.JWTAuth(false), updateCredentialHandler)
|
||||
group.DELETE("/webauthn/credentials/:id", middleware.JWTAuth(false), deleteCredentialHandler)
|
||||
}
|
||||
|
||||
func beginRegistrationHandler(c *gin.Context) {
|
||||
var user model.User
|
||||
err := common.DB.Preload("Credentials").Find(&user, "id = ?", c.GetString("userID")).Error
|
||||
if err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
log.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
options, session, err := common.WebAuthn.BeginRegistration(&user, webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementRequired), webauthn.WithExclusions(user.WebAuthnCredentialDescriptors()))
|
||||
if err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Save the webauthn session so we can retrieve it in the verifyRegistrationHandler
|
||||
sessionToStore := &model.WebauthnSession{
|
||||
ExpiresAt: session.Expires,
|
||||
Challenge: session.Challenge,
|
||||
UserVerification: string(session.UserVerification),
|
||||
}
|
||||
|
||||
if err = common.DB.Create(&sessionToStore).Error; err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.SetCookie("session_id", sessionToStore.ID, int(common.WebAuthn.Config.Timeouts.Registration.Timeout.Seconds()), "/", "", false, true)
|
||||
c.JSON(http.StatusOK, options.Response)
|
||||
}
|
||||
|
||||
func verifyRegistrationHandler(c *gin.Context) {
|
||||
sessionID, err := c.Cookie("session_id")
|
||||
if err != nil {
|
||||
utils.HandlerError(c, http.StatusBadRequest, "Session ID missing")
|
||||
return
|
||||
}
|
||||
|
||||
// Retrieve the session that was previously created by the beginRegistrationHandler
|
||||
var storedSession model.WebauthnSession
|
||||
err = common.DB.First(&storedSession, "id = ?", sessionID).Error
|
||||
|
||||
session := webauthn.SessionData{
|
||||
Challenge: storedSession.Challenge,
|
||||
Expires: storedSession.ExpiresAt,
|
||||
UserID: []byte(c.GetString("userID")),
|
||||
}
|
||||
|
||||
var user model.User
|
||||
err = common.DB.Find(&user, "id = ?", c.GetString("userID")).Error
|
||||
if err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
credential, err := common.WebAuthn.FinishRegistration(&user, session, c.Request)
|
||||
if err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
credentialToStore := model.WebauthnCredential{
|
||||
Name: "New Passkey",
|
||||
CredentialID: string(credential.ID),
|
||||
AttestationType: credential.AttestationType,
|
||||
PublicKey: credential.PublicKey,
|
||||
Transport: credential.Transport,
|
||||
UserID: user.ID,
|
||||
}
|
||||
if err := common.DB.Create(&credentialToStore).Error; err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, credentialToStore)
|
||||
}
|
||||
|
||||
func beginLoginHandler(c *gin.Context) {
|
||||
options, session, err := common.WebAuthn.BeginDiscoverableLogin()
|
||||
if err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Save the webauthn session so we can retrieve it in the verifyLoginHandler
|
||||
sessionToStore := &model.WebauthnSession{
|
||||
ExpiresAt: session.Expires,
|
||||
Challenge: session.Challenge,
|
||||
UserVerification: string(session.UserVerification),
|
||||
}
|
||||
|
||||
if err = common.DB.Create(&sessionToStore).Error; err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.SetCookie("session_id", sessionToStore.ID, int(common.WebAuthn.Config.Timeouts.Registration.Timeout.Seconds()), "/", "", false, true)
|
||||
c.JSON(http.StatusOK, options.Response)
|
||||
}
|
||||
|
||||
func verifyLoginHandler(c *gin.Context) {
|
||||
sessionID, err := c.Cookie("session_id")
|
||||
if err != nil {
|
||||
utils.HandlerError(c, http.StatusBadRequest, "Session ID missing")
|
||||
return
|
||||
}
|
||||
|
||||
credentialAssertionData, err := protocol.ParseCredentialRequestResponseBody(c.Request.Body)
|
||||
if err != nil {
|
||||
utils.HandlerError(c, http.StatusBadRequest, "Invalid body")
|
||||
return
|
||||
}
|
||||
|
||||
// Retrieve the session that was previously created by the beginLoginHandler
|
||||
var storedSession model.WebauthnSession
|
||||
if err := common.DB.First(&storedSession, "id = ?", sessionID).Error; err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
session := webauthn.SessionData{
|
||||
Challenge: storedSession.Challenge,
|
||||
Expires: storedSession.ExpiresAt,
|
||||
}
|
||||
|
||||
var user *model.User
|
||||
_, err = common.WebAuthn.ValidateDiscoverableLogin(func(_, userHandle []byte) (webauthn.User, error) {
|
||||
if err := common.DB.Preload("Credentials").First(&user, "id = ?", string(userHandle)).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return user, nil
|
||||
}, session, credentialAssertionData)
|
||||
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), gorm.ErrRecordNotFound.Error()) {
|
||||
utils.HandlerError(c, http.StatusBadRequest, "no user with this passkey exists")
|
||||
} else {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
err = common.DB.Find(&user, "id = ?", c.GetString("userID")).Error
|
||||
if err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := common.GenerateAccessToken(*user)
|
||||
if err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.SetCookie("access_token", token, int(time.Hour.Seconds()), "/", "", false, true)
|
||||
c.JSON(http.StatusOK, user)
|
||||
}
|
||||
|
||||
func listCredentialsHandler(c *gin.Context) {
|
||||
var credentials []model.WebauthnCredential
|
||||
if err := common.DB.Find(&credentials, "user_id = ?", c.GetString("userID")).Error; err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, credentials)
|
||||
}
|
||||
|
||||
func deleteCredentialHandler(c *gin.Context) {
|
||||
var passkeyCount int64
|
||||
if err := common.DB.Model(&model.WebauthnCredential{}).Where("user_id = ?", c.GetString("userID")).Count(&passkeyCount).Error; err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
if passkeyCount == 1 {
|
||||
utils.HandlerError(c, http.StatusBadRequest, "You must have at least one passkey")
|
||||
return
|
||||
}
|
||||
|
||||
var credential model.WebauthnCredential
|
||||
if err := common.DB.First(&credential, "id = ? AND user_id = ?", c.Param("id"), c.GetString("userID")).Error; err != nil {
|
||||
utils.HandlerError(c, http.StatusNotFound, "Credential not found")
|
||||
return
|
||||
}
|
||||
|
||||
if err := common.DB.Delete(&credential).Error; err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func updateCredentialHandler(c *gin.Context) {
|
||||
var credential model.WebauthnCredential
|
||||
if err := common.DB.Where("id = ? AND user_id = ?", c.Param("id"), c.GetString("userID")).First(&credential).Error; err != nil {
|
||||
utils.HandlerError(c, http.StatusNotFound, "Credential not found")
|
||||
return
|
||||
}
|
||||
|
||||
var input struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
utils.HandlerError(c, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
credential.Name = input.Name
|
||||
|
||||
if err := common.DB.Save(&credential).Error; err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func logoutHandler(c *gin.Context) {
|
||||
c.SetCookie("access_token", "", 0, "/", "", false, true)
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
39
backend/internal/handler/well_known.go
Normal file
39
backend/internal/handler/well_known.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang-rest-api-template/internal/common"
|
||||
"golang-rest-api-template/internal/utils"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func RegisterWellKnownRoutes(group *gin.RouterGroup) {
|
||||
group.GET("/.well-known/jwks.json", jwks)
|
||||
group.GET("/.well-known/openid-configuration", openIDConfiguration)
|
||||
}
|
||||
|
||||
func jwks(c *gin.Context) {
|
||||
jwk, err := common.GetJWK()
|
||||
if err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"keys": []interface{}{jwk}})
|
||||
}
|
||||
|
||||
func openIDConfiguration(c *gin.Context) {
|
||||
appUrl := common.EnvConfig.AppURL
|
||||
config := map[string]interface{}{
|
||||
"issuer": appUrl,
|
||||
"authorization_endpoint": appUrl + "/authorize",
|
||||
"token_endpoint": appUrl + "/api/oidc/token",
|
||||
"jwks_uri": appUrl + "/.well-known/jwks.json",
|
||||
"scopes_supported": []string{"openid", "profile", "email"},
|
||||
"claims_supported": []string{"sub", "given_name", "family_name", "email", "preferred_username"},
|
||||
"response_types_supported": []string{"code", "id_token"},
|
||||
"subject_types_supported": []string{"public"},
|
||||
"id_token_signing_alg_values_supported": []string{"RS256"},
|
||||
}
|
||||
c.JSON(http.StatusOK, config)
|
||||
}
|
||||
57
backend/internal/job/db_cleanup.go
Normal file
57
backend/internal/job/db_cleanup.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package job
|
||||
|
||||
import (
|
||||
"github.com/go-co-op/gocron/v2"
|
||||
"github.com/google/uuid"
|
||||
"golang-rest-api-template/internal/common"
|
||||
"golang-rest-api-template/internal/model"
|
||||
"golang-rest-api-template/internal/utils"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
func RegisterJobs() {
|
||||
scheduler, err := gocron.NewScheduler()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create a new scheduler: %s", err)
|
||||
}
|
||||
|
||||
registerJob(scheduler, "ClearWebauthnSessions", "0 3 * * *", clearWebauthnSessions)
|
||||
registerJob(scheduler, "ClearOneTimeAccessTokens", "0 3 * * *", clearOneTimeAccessTokens)
|
||||
registerJob(scheduler, "ClearOidcAuthorizationCodes", "0 3 * * *", clearOidcAuthorizationCodes)
|
||||
|
||||
scheduler.Start()
|
||||
}
|
||||
|
||||
func registerJob(scheduler gocron.Scheduler, name string, interval string, job func() error) {
|
||||
|
||||
_, err := scheduler.NewJob(
|
||||
gocron.CronJob(interval, false),
|
||||
gocron.NewTask(job),
|
||||
gocron.WithEventListeners(
|
||||
gocron.AfterJobRuns(func(jobID uuid.UUID, jobName string) {
|
||||
log.Printf("Job %q run successfully", name)
|
||||
}),
|
||||
gocron.AfterJobRunsWithError(func(jobID uuid.UUID, jobName string, err error) {
|
||||
log.Printf("Job %q failed with error: %v", name, err)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to register job %q: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func clearWebauthnSessions() error {
|
||||
return common.DB.Delete(&model.WebauthnSession{}, "expires_at < ?", utils.FormatDateForDb(time.Now())).Error
|
||||
}
|
||||
|
||||
func clearOneTimeAccessTokens() error {
|
||||
return common.DB.Debug().Delete(&model.OneTimeAccessToken{}, "expires_at < ?", utils.FormatDateForDb(time.Now())).Error
|
||||
}
|
||||
|
||||
func clearOidcAuthorizationCodes() error {
|
||||
return common.DB.Delete(&model.OidcAuthorizationCode{}, "expires_at < ?", utils.FormatDateForDb(time.Now())).Error
|
||||
|
||||
}
|
||||
19
backend/internal/model/application_configuration.go
Normal file
19
backend/internal/model/application_configuration.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package model
|
||||
|
||||
type ApplicationConfigurationVariable struct {
|
||||
Key string `gorm:"primaryKey;not null" json:"key"`
|
||||
Type string `json:"type"`
|
||||
IsPublic bool `json:"-"`
|
||||
IsInternal bool `json:"-"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type ApplicationConfiguration struct {
|
||||
AppName ApplicationConfigurationVariable
|
||||
BackgroundImageType ApplicationConfigurationVariable
|
||||
LogoImageType ApplicationConfigurationVariable
|
||||
}
|
||||
|
||||
type ApplicationConfigurationUpdateDto struct {
|
||||
AppName string `json:"appName" binding:"required"`
|
||||
}
|
||||
20
backend/internal/model/base.go
Normal file
20
backend/internal/model/base.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Base contains common columns for all tables.
|
||||
type Base struct {
|
||||
ID string `gorm:"primaryKey;not null" json:"id"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
func (b *Base) BeforeCreate(db *gorm.DB) (err error) {
|
||||
if b.ID == "" {
|
||||
b.ID = uuid.New().String()
|
||||
}
|
||||
return
|
||||
}
|
||||
65
backend/internal/model/oidc.go
Normal file
65
backend/internal/model/oidc.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
"time"
|
||||
)
|
||||
|
||||
type UserAuthorizedOidcClient struct {
|
||||
Scope string
|
||||
UserID string `json:"userId" gorm:"primary_key;"`
|
||||
|
||||
ClientID string `json:"clientId" gorm:"primary_key;"`
|
||||
Client OidcClient
|
||||
}
|
||||
|
||||
type OidcClient struct {
|
||||
Base
|
||||
|
||||
Name string `json:"name"`
|
||||
Secret string `json:"-"`
|
||||
CallbackURL string `json:"callbackURL"`
|
||||
ImageType *string `json:"-"`
|
||||
HasLogo bool `gorm:"-" json:"hasLogo"`
|
||||
|
||||
CreatedByID string
|
||||
CreatedBy User
|
||||
}
|
||||
|
||||
func (c *OidcClient) AfterFind(_ *gorm.DB) (err error) {
|
||||
// Compute HasLogo field
|
||||
c.HasLogo = c.ImageType != nil && *c.ImageType != ""
|
||||
return nil
|
||||
}
|
||||
|
||||
type OidcAuthorizationCode struct {
|
||||
Base
|
||||
|
||||
Code string
|
||||
Scope string
|
||||
Nonce string
|
||||
ExpiresAt time.Time
|
||||
|
||||
UserID string
|
||||
User User
|
||||
|
||||
ClientID string
|
||||
}
|
||||
|
||||
type OidcClientCreateDto struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
CallbackURL string `json:"callbackURL" binding:"required"`
|
||||
}
|
||||
|
||||
type AuthorizeNewClientDto struct {
|
||||
ClientID string `json:"clientID" binding:"required"`
|
||||
Scope string `json:"scope" binding:"required"`
|
||||
Nonce string `json:"nonce"`
|
||||
}
|
||||
|
||||
type OidcIdTokenDto struct {
|
||||
GrantType string `form:"grant_type" binding:"required"`
|
||||
Code string `form:"code" binding:"required"`
|
||||
ClientID string `form:"client_id"`
|
||||
ClientSecret string `form:"client_secret"`
|
||||
}
|
||||
73
backend/internal/model/user.go
Normal file
73
backend/internal/model/user.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
"time"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
Base
|
||||
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email" `
|
||||
FirstName string `json:"firstName"`
|
||||
LastName string `json:"lastName"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
|
||||
Credentials []WebauthnCredential `json:"-"`
|
||||
}
|
||||
|
||||
func (u User) WebAuthnID() []byte { return []byte(u.ID) }
|
||||
|
||||
func (u User) WebAuthnName() string { return u.Username }
|
||||
|
||||
func (u User) WebAuthnDisplayName() string { return u.FirstName + " " + u.LastName }
|
||||
|
||||
func (u User) WebAuthnIcon() string { return "" }
|
||||
|
||||
func (u User) WebAuthnCredentials() []webauthn.Credential {
|
||||
credentials := make([]webauthn.Credential, len(u.Credentials))
|
||||
|
||||
for i, credential := range u.Credentials {
|
||||
credentials[i] = webauthn.Credential{
|
||||
ID: []byte(credential.CredentialID),
|
||||
AttestationType: credential.AttestationType,
|
||||
PublicKey: credential.PublicKey,
|
||||
Transport: credential.Transport,
|
||||
}
|
||||
|
||||
}
|
||||
return credentials
|
||||
}
|
||||
|
||||
func (u User) WebAuthnCredentialDescriptors() (descriptors []protocol.CredentialDescriptor) {
|
||||
credentials := u.WebAuthnCredentials()
|
||||
|
||||
descriptors = make([]protocol.CredentialDescriptor, len(credentials))
|
||||
|
||||
for i, credential := range credentials {
|
||||
descriptors[i] = credential.Descriptor()
|
||||
}
|
||||
|
||||
return descriptors
|
||||
}
|
||||
|
||||
type OneTimeAccessToken struct {
|
||||
Base
|
||||
Token string `json:"token"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
|
||||
UserID string `json:"userId"`
|
||||
User User
|
||||
}
|
||||
|
||||
type OneTimeAccessTokenCreateDto struct {
|
||||
UserID string `json:"userId" binding:"required"`
|
||||
ExpiresAt time.Time `json:"expiresAt" binding:"required"`
|
||||
}
|
||||
|
||||
type LoginUserDto struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
45
backend/internal/model/webauthn.go
Normal file
45
backend/internal/model/webauthn.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
"time"
|
||||
)
|
||||
|
||||
type WebauthnSession struct {
|
||||
Base
|
||||
|
||||
Challenge string
|
||||
ExpiresAt time.Time
|
||||
UserVerification string
|
||||
}
|
||||
|
||||
type WebauthnCredential struct {
|
||||
Base
|
||||
|
||||
Name string `json:"name"`
|
||||
CredentialID string `json:"credentialID"`
|
||||
PublicKey []byte `json:"publicKey"`
|
||||
AttestationType string `json:"attestationType"`
|
||||
Transport AuthenticatorTransportList `json:"-"`
|
||||
|
||||
UserID string
|
||||
}
|
||||
|
||||
type AuthenticatorTransportList []protocol.AuthenticatorTransport
|
||||
|
||||
// Scan and Value methods for GORM to handle the custom type
|
||||
func (atl *AuthenticatorTransportList) Scan(value interface{}) error {
|
||||
|
||||
if v, ok := value.([]byte); ok {
|
||||
return json.Unmarshal(v, atl)
|
||||
} else {
|
||||
return errors.New("type assertion to []byte failed")
|
||||
}
|
||||
}
|
||||
|
||||
func (atl AuthenticatorTransportList) Value() (driver.Value, error) {
|
||||
return json.Marshal(atl)
|
||||
}
|
||||
73
backend/internal/utils/file_util.go
Normal file
73
backend/internal/utils/file_util.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func GetFileExtension(filename string) string {
|
||||
splitted := strings.Split(filename, ".")
|
||||
return splitted[len(splitted)-1]
|
||||
}
|
||||
|
||||
func GetImageMimeType(ext string) string {
|
||||
switch ext {
|
||||
case "jpg", "jpeg":
|
||||
return "image/jpeg"
|
||||
case "png":
|
||||
return "image/png"
|
||||
case "svg":
|
||||
return "image/svg+xml"
|
||||
case "ico":
|
||||
return "image/x-icon"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func CopyDirectory(srcDir, destDir string) error {
|
||||
files, err := os.ReadDir(srcDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, file := range files {
|
||||
srcFilePath := filepath.Join(srcDir, file.Name())
|
||||
destFilePath := filepath.Join(destDir, file.Name())
|
||||
|
||||
err := copyFile(srcFilePath, destFilePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyFile(srcFilePath, destFilePath string) error {
|
||||
srcFile, err := os.Open(srcFilePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer srcFile.Close()
|
||||
|
||||
err = os.MkdirAll(filepath.Dir(destFilePath), os.ModePerm)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
destFile, err := os.Create(destFilePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer destFile.Close()
|
||||
|
||||
_, err = io.Copy(destFile, srcFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
19
backend/internal/utils/handler_error_util.go
Normal file
19
backend/internal/utils/handler_error_util.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func UnknownHandlerError(c *gin.Context, err error) {
|
||||
log.Println(err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Something went wrong"})
|
||||
}
|
||||
|
||||
func HandlerError(c *gin.Context, statusCode int, message string) {
|
||||
// Capitalize the first letter of the message
|
||||
message = strings.ToUpper(message[:1]) + message[1:]
|
||||
c.JSON(statusCode, gin.H{"error": message})
|
||||
}
|
||||
45
backend/internal/utils/paging_util.go
Normal file
45
backend/internal/utils/paging_util.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type PaginationResponse struct {
|
||||
TotalPages int64 `json:"totalPages"`
|
||||
TotalItems int64 `json:"totalItems"`
|
||||
CurrentPage int `json:"currentPage"`
|
||||
}
|
||||
|
||||
func Paginate(c *gin.Context, db *gorm.DB, result interface{}) (PaginationResponse, error) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
|
||||
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
|
||||
if pageSize < 1 {
|
||||
pageSize = 10
|
||||
} else if pageSize > 100 {
|
||||
pageSize = 100
|
||||
}
|
||||
|
||||
offset := (page - 1) * pageSize
|
||||
|
||||
var totalItems int64
|
||||
if err := db.Count(&totalItems).Error; err != nil {
|
||||
return PaginationResponse{}, err
|
||||
}
|
||||
|
||||
if err := db.Offset(offset).Limit(pageSize).Find(result).Error; err != nil {
|
||||
return PaginationResponse{}, err
|
||||
}
|
||||
|
||||
return PaginationResponse{
|
||||
TotalPages: (totalItems + int64(pageSize) - 1) / int64(pageSize),
|
||||
TotalItems: totalItems,
|
||||
CurrentPage: page,
|
||||
}, nil
|
||||
}
|
||||
43
backend/internal/utils/string_util.go
Normal file
43
backend/internal/utils/string_util.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// GenerateRandomAlphanumericString generates a random alphanumeric string of the given length
|
||||
func GenerateRandomAlphanumericString(length int) (string, error) {
|
||||
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
const charsetLength = int64(len(charset))
|
||||
|
||||
if length <= 0 {
|
||||
return "", fmt.Errorf("length must be a positive integer")
|
||||
}
|
||||
|
||||
result := make([]byte, length)
|
||||
|
||||
for i := range result {
|
||||
num, err := rand.Int(rand.Reader, big.NewInt(charsetLength))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
result[i] = charset[num.Int64()]
|
||||
}
|
||||
|
||||
return string(result), nil
|
||||
}
|
||||
|
||||
func GetHostFromURL(rawURL string) string {
|
||||
parsedURL, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return parsedURL.Host
|
||||
}
|
||||
|
||||
// StringPointer creates a string pointer from a string value
|
||||
func StringPointer(s string) *string {
|
||||
return &s
|
||||
}
|
||||
8
backend/internal/utils/time_util.go
Normal file
8
backend/internal/utils/time_util.go
Normal file
@@ -0,0 +1,8 @@
|
||||
package utils
|
||||
|
||||
import "time"
|
||||
|
||||
func FormatDateForDb(time time.Time) string {
|
||||
const layout = "2006-01-02 15:04:05.000-07:00"
|
||||
return time.Format(layout)
|
||||
}
|
||||
Reference in New Issue
Block a user