Compare commits

...

2 Commits

Author SHA1 Message Date
fabio 5b9fe6c9b7 Refactor configuration and database initialization: replace LoadConfig with GetConfig, introduce DbConfig struct, and streamline token service retrieval. Remove unused request types and enhance user refresh token handling. 2026-04-09 09:21:38 +02:00
fabio 3731e6e409 Refactor user management module: rename package, restructure user controller, and implement authentication features
- Renamed package from `user` to `users` for clarity.
- Updated UserController to include token service for authentication.
- Implemented user login, registration, password reset, and token validation functionalities.
- Introduced user roles and permissions management.
- Added session tracking for user logins.
- Created migration script for user-related database tables.
- Refactored user model to include details and preferences.
- Enhanced password handling with secure hashing and verification.
- Updated routes to include new authentication endpoints and middleware.
2026-04-06 21:37:06 +02:00
26 changed files with 1144 additions and 1276 deletions

View File

@ -4,7 +4,7 @@
// //
// This file was generated by github.com/millevolte/ts-rpc // This file was generated by github.com/millevolte/ts-rpc
// //
// Apr 05, 2026 20:12:24 UTC // Apr 06, 2026 16:56:35 UTC
// //
export interface ApiRestResponse { export interface ApiRestResponse {
@ -280,10 +280,205 @@ export type Nullable<T> = T | null;
export type Record<K extends string | number | symbol, T> = { [P in K]: T }; export type Record<K extends string | number | symbol, T> = { [P in K]: T };
//
// package user
//
// Typescript: TSEndpoint= path=/users/:uuid; name=getUser; method=GET; response=models.UserProfile
// internal/user/routes.go Line: 13
export const getUser = async (
uuid: string,
): Promise<{ data: UserProfile; error: Nullable<string> }> => {
return (await api.GET(`/users/${uuid}`)) as {
data: UserProfile;
error: Nullable<string>;
};
};
// Typescript: TSEndpoint= path=/users; name=createUser; method=POST; request=models.UserCreateInput; response=models.UserProfile
// internal/user/routes.go Line: 16
export const createUser = async (
data: UserCreateInput,
): Promise<{ data: UserProfile; error: Nullable<string> }> => {
return (await api.POST("/users", data)) as {
data: UserProfile;
error: Nullable<string>;
};
};
// Typescript: TSEndpoint= path=/users/:uuid; name=updateUser; method=PUT; request=controllers.UpdateUserRequest; response=models.UserProfile
// internal/user/routes.go Line: 19
export const updateUser = async (
data: UpdateUserRequest,
): Promise<{ data: UserProfile; error: Nullable<string> }> => {
return (await api.PUT("/users/:uuid", data)) as {
data: UserProfile;
error: Nullable<string>;
};
};
// Typescript: TSEndpoint= path=/users/:uuid; name=deleteUser; method=DELETE; response=controllers.SimpleResponse
// internal/user/routes.go Line: 22
export const deleteUser = async (
uuid: string,
): Promise<{ data: SimpleResponse; error: Nullable<string> }> => {
return (await api.DELETE(`/users/${uuid}`)) as {
data: SimpleResponse;
error: Nullable<string>;
};
};
// Typescript: TSEndpoint= path=/auth/me; name=me; method=GET; response=models.UserShort
// internal/user/routes.go Line: 25
export const me = async (): Promise<{
data: UserShort;
error: Nullable<string>;
}> => {
return (await api.GET("/auth/me")) as {
data: UserShort;
error: Nullable<string>;
};
};
export interface UpdateUserRequest {
name: string;
email: string;
password: string;
roles: models.UserRoles;
status: models.UserStatus;
types: models.UserTypes;
avatar: Nullable<string>;
details: Nullable<models.UserDetailsShort>;
preferences: Nullable<models.UserPreferencesShort>;
}
//
// package models
//
export interface UserDetailsShort {
title: string;
firstName: string;
lastName: string;
address: string;
city: string;
zipCode: string;
country: string;
phone: string;
}
export interface UserPreferencesShort {
useIdle: boolean;
idleTimeout: number;
useIdlePassword: boolean;
idlePin: string;
useDirectLogin: boolean;
useQuadcodeLogin: boolean;
sendNoticesMail: boolean;
language: string;
}
export interface UserShort {
email: string;
name: string;
roles: UserRoles;
status: UserStatus;
uuid: string;
details: Nullable<UserDetailsShort>;
preferences: Nullable<UserPreferencesShort>;
avatar: Nullable<string>;
}
export interface UserCreateInput {
name: string;
email: string;
password: string;
roles: UserRoles;
status: UserStatus;
types: UserTypes;
avatar: Nullable<string>;
details: Nullable<UserDetailsShort>;
preferences: Nullable<UserPreferencesShort>;
}
export type UserRoles = string[];
export type UserStatus = (typeof EnumUserStatus)[keyof typeof EnumUserStatus];
export type UserTypes = string[];
export type UsersShort = UserShort[];
export const EnumUserStatus = {
UserStatusPending: "pending",
UserStatusActive: "active",
UserStatusDisabled: "disabled",
} as const;
//
// package admin
//
// Typescript: TSEndpoint= path=/admin/users/:uuid/block; name=blockUser; method=PUT; request=admin.BlockUserRequest; response=models.UserShort
// internal/admin/routes.go Line: 16
export const blockUser = async (
data: BlockUserRequest,
): Promise<{ data: UserShort; error: Nullable<string> }> => {
return (await api.PUT("/admin/users/:uuid/block", data)) as {
data: UserShort;
error: Nullable<string>;
};
};
// Typescript: TSEndpoint= path=/admin/users; name=listUsers; method=POST; request=admin.ListUsersRequest; response=models.[]UserShort
// internal/admin/routes.go Line: 12
export const listUsers = async (
data: ListUsersRequest,
): Promise<{ data: UserShort[]; error: Nullable<string> }> => {
return (await api.POST("/admin/users", data)) as {
data: UserShort[];
error: Nullable<string>;
};
};
export interface BlockUserRequest {
action: string;
}
export interface ListUsersRequest {
page: number;
pageSize: number;
}
//
// package responses
//
export interface SimpleResponse {
message: string;
}
// //
// package systemUtils // package systemUtils
// //
// Typescript: TSEndpoint= path=/health; name=health; method=GET; response=string
// internal/systemUtils/routes.go Line: 34
export const health = async (): Promise<{
data: string;
error: Nullable<string>;
}> => {
return (await api.GET("/health")) as {
data: string;
error: Nullable<string>;
};
};
// Typescript: TSEndpoint= path=/metrics; name=metrics; method=GET; response=string // Typescript: TSEndpoint= path=/metrics; name=metrics; method=GET; response=string
// internal/systemUtils/routes.go Line: 37 // internal/systemUtils/routes.go Line: 37
export const metrics = async (): Promise<{ export const metrics = async (): Promise<{
@ -308,114 +503,30 @@ export const mailDebug = async (): Promise<{
}; };
}; };
// Typescript: TSEndpoint= path=/health; name=health; method=GET; response=string
// internal/systemUtils/routes.go Line: 34
export const health = async (): Promise<{
data: string;
error: Nullable<string>;
}> => {
return (await api.GET("/health")) as {
data: string;
error: Nullable<string>;
};
};
export interface MailDebugItem { export interface MailDebugItem {
name: string; name: string;
content: string; content: string;
} }
// //
// package admin // package routes
// //
// Typescript: TSEndpoint= path=/admin/users; name=listUsers; method=POST; request=admin.ListUsersRequest; response=models.[]UserShort export interface FormRequest {
// internal/admin/routes.go Line: 12 req: string;
count: number;
export const listUsers = async (
data: ListUsersRequest,
): Promise<{ data: UserShort[]; error: Nullable<string> }> => {
return (await api.POST("/admin/users", data)) as {
data: UserShort[];
error: Nullable<string>;
};
};
// Typescript: TSEndpoint= path=/admin/users/:uuid/block; name=blockUser; method=PUT; request=admin.BlockUserRequest; response=models.UserShort
// internal/admin/routes.go Line: 16
export const blockUser = async (
data: BlockUserRequest,
): Promise<{ data: UserShort; error: Nullable<string> }> => {
return (await api.PUT("/admin/users/:uuid/block", data)) as {
data: UserShort;
error: Nullable<string>;
};
};
export interface BlockUserRequest {
action: string;
} }
export interface ListUsersRequest { export interface FormResponse {
page: number; test: string;
pageSize: number;
} }
// //
// package auth // package auth
// //
// Typescript: TSEndpoint= path=/auth/refresh; name=refresh; method=POST; request=model.RefreshRequest; response=model.TokenPair
// internal/auth/routes.go Line: 23
export const refresh = async (
data: RefreshRequest,
): Promise<{ data: TokenPair; error: Nullable<string> }> => {
return (await api.POST("/auth/refresh", data)) as {
data: TokenPair;
error: Nullable<string>;
};
};
// Typescript: TSEndpoint= path=/auth/me; name=me; method=GET; response=models.UserShort
// internal/auth/routes.go Line: 26
export const me = async (): Promise<{
data: UserShort;
error: Nullable<string>;
}> => {
return (await api.GET("/auth/me")) as {
data: UserShort;
error: Nullable<string>;
};
};
// Typescript: TSEndpoint= path=/auth/register; name=register; method=POST; request=models.UserCreateInput; response=models.UserShort
// internal/auth/routes.go Line: 29
export const register = async (
data: UserCreateInput,
): Promise<{ data: UserShort; error: Nullable<string> }> => {
return (await api.POST("/auth/register", data)) as {
data: UserShort;
error: Nullable<string>;
};
};
// Typescript: TSEndpoint= path=/auth/password/forgot; name=forgotPassword; method=POST; request=model.ForgotPasswordRequest; response=controllers.SimpleResponse
// internal/auth/routes.go Line: 32
export const forgotPassword = async (
data: ForgotPasswordRequest,
): Promise<{ data: SimpleResponse; error: Nullable<string> }> => {
return (await api.POST("/auth/password/forgot", data)) as {
data: SimpleResponse;
error: Nullable<string>;
};
};
// Typescript: TSEndpoint= path=/auth/password/reset; name=resetPassword; method=POST; request=model.ResetPasswordRequest; response=controllers.SimpleResponse // Typescript: TSEndpoint= path=/auth/password/reset; name=resetPassword; method=POST; request=model.ResetPasswordRequest; response=controllers.SimpleResponse
// internal/auth/routes.go Line: 35 // internal/auth/routes.go Line: 32
export const resetPassword = async ( export const resetPassword = async (
data: ResetPasswordRequest, data: ResetPasswordRequest,
@ -427,7 +538,7 @@ export const resetPassword = async (
}; };
// Typescript: TSEndpoint= path=/auth/password/valid; name=validToken; method=POST; request=string; response=controllers.SimpleResponse // Typescript: TSEndpoint= path=/auth/password/valid; name=validToken; method=POST; request=string; response=controllers.SimpleResponse
// internal/auth/routes.go Line: 38 // internal/auth/routes.go Line: 35
export const validToken = async ( export const validToken = async (
data: string, data: string,
@ -450,172 +561,61 @@ export const login = async (
}; };
}; };
export interface ResetPasswordRequest { // Typescript: TSEndpoint= path=/auth/refresh; name=refresh; method=POST; request=model.RefreshRequest; response=model.TokenPair
token: string; // internal/auth/routes.go Line: 23
password: string;
} export const refresh = async (
data: RefreshRequest,
): Promise<{ data: TokenPair; error: Nullable<string> }> => {
return (await api.POST("/auth/refresh", data)) as {
data: TokenPair;
error: Nullable<string>;
};
};
// Typescript: TSEndpoint= path=/auth/register; name=register; method=POST; request=models.UserCreateInput; response=models.UserShort
// internal/auth/routes.go Line: 26
export const register = async (
data: UserCreateInput,
): Promise<{ data: UserShort; error: Nullable<string> }> => {
return (await api.POST("/auth/register", data)) as {
data: UserShort;
error: Nullable<string>;
};
};
// Typescript: TSEndpoint= path=/auth/password/forgot; name=forgotPassword; method=POST; request=model.ForgotPasswordRequest; response=controllers.SimpleResponse
// internal/auth/routes.go Line: 29
export const forgotPassword = async (
data: ForgotPasswordRequest,
): Promise<{ data: SimpleResponse; error: Nullable<string> }> => {
return (await api.POST("/auth/password/forgot", data)) as {
data: SimpleResponse;
error: Nullable<string>;
};
};
export interface TokenPair { export interface TokenPair {
access_token: string; access_token: string;
refresh_token: string; refresh_token: string;
} }
export interface ForgotPasswordRequest {
email: string;
}
export interface LoginRequest { export interface LoginRequest {
username: string; username: string;
password: string; password: string;
} }
export interface ForgotPasswordRequest {
email: string;
}
export interface RefreshRequest { export interface RefreshRequest {
refresh_token: string; refresh_token: string;
} }
// export interface ResetPasswordRequest {
// package user token: string;
//
// Typescript: TSEndpoint= path=/users/:uuid; name=updateUser; method=PUT; request=controllers.UpdateUserRequest; response=models.UserProfile
// internal/user/routes.go Line: 18
export const updateUser = async (
data: UpdateUserRequest,
): Promise<{ data: UserProfile; error: Nullable<string> }> => {
return (await api.PUT("/users/:uuid", data)) as {
data: UserProfile;
error: Nullable<string>;
};
};
// Typescript: TSEndpoint= path=/users/:uuid; name=deleteUser; method=DELETE; response=controllers.SimpleResponse
// internal/user/routes.go Line: 21
export const deleteUser = async (
uuid: string,
): Promise<{ data: SimpleResponse; error: Nullable<string> }> => {
return (await api.DELETE(`/users/${uuid}`)) as {
data: SimpleResponse;
error: Nullable<string>;
};
};
// Typescript: TSEndpoint= path=/users/:uuid; name=getUser; method=GET; response=models.UserProfile
// internal/user/routes.go Line: 12
export const getUser = async (
uuid: string,
): Promise<{ data: UserProfile; error: Nullable<string> }> => {
return (await api.GET(`/users/${uuid}`)) as {
data: UserProfile;
error: Nullable<string>;
};
};
// Typescript: TSEndpoint= path=/users; name=createUser; method=POST; request=models.UserCreateInput; response=models.UserProfile
// internal/user/routes.go Line: 15
export const createUser = async (
data: UserCreateInput,
): Promise<{ data: UserProfile; error: Nullable<string> }> => {
return (await api.POST("/users", data)) as {
data: UserProfile;
error: Nullable<string>;
};
};
export interface UpdateUserRequest {
name: string;
email: string;
password: string; password: string;
roles: models.UserRoles;
status: models.UserStatus;
types: models.UserTypes;
avatar: Nullable<string>;
details: Nullable<models.UserDetailsShort>;
preferences: Nullable<models.UserPreferencesShort>;
} }
//
// package responses
//
export interface SimpleResponse {
message: string;
}
//
// package routes
//
export interface FormRequest {
req: string;
count: number;
}
export interface FormResponse {
test: string;
}
//
// package models
//
export interface UserCreateInput {
name: string;
email: string;
password: string;
roles: UserRoles;
status: UserStatus;
types: UserTypes;
avatar: Nullable<string>;
details: Nullable<UserDetailsShort>;
preferences: Nullable<UserPreferencesShort>;
}
export interface UserPreferencesShort {
useIdle: boolean;
idleTimeout: number;
useIdlePassword: boolean;
idlePin: string;
useDirectLogin: boolean;
useQuadcodeLogin: boolean;
sendNoticesMail: boolean;
language: string;
}
export interface UserDetailsShort {
title: string;
firstName: string;
lastName: string;
address: string;
city: string;
zipCode: string;
country: string;
phone: string;
}
export interface UserShort {
email: string;
name: string;
roles: UserRoles;
status: UserStatus;
uuid: string;
details: Nullable<UserDetailsShort>;
preferences: Nullable<UserPreferencesShort>;
avatar: Nullable<string>;
}
export type UserTypes = string[];
export type UsersShort = UserShort[];
export type UserRoles = string[];
export type UserStatus = (typeof EnumUserStatus)[keyof typeof EnumUserStatus];
export const EnumUserStatus = {
UserStatusPending: "pending",
UserStatusActive: "active",
UserStatusDisabled: "disabled",
} as const;

View File

@ -11,13 +11,13 @@ import (
"syscall" "syscall"
"time" "time"
"server/internal/auth"
"server/internal/authorization"
"server/internal/config" "server/internal/config"
"server/internal/db" "server/internal/db"
"server/internal/migrations"
"server/internal/roles"
"server/internal/routes" "server/internal/routes"
"server/internal/tokens"
"server/internal/mail"
"server/internal/seed" "server/internal/seed"
"github.com/gofiber/fiber/v3" "github.com/gofiber/fiber/v3"
@ -36,51 +36,23 @@ func main() {
seedCount := flag.Int("seed", 0, "seed N fake users at startup (0 to skip)") seedCount := flag.Int("seed", 0, "seed N fake users at startup (0 to skip)")
flag.Parse() flag.Parse()
configPath := envOrDefault("CONFIG_PATH", "configs/config.json") cfg, err := config.GetConfig()
cfg, err := config.LoadConfig(configPath)
if err != nil { if err != nil {
log.Fatalf("load config: %v", err) log.Fatalf("config: %v", err)
}
if secret := os.Getenv("AUTH_SECRET"); secret != "" {
cfg.Auth.Secret = secret
} }
dbCfg := db.Config{ dbConn, err := db.GetDB()
Driver: envOrDefault("DB_driver", "sqlite"),
DSN: envOrDefault("DB_dsn", "file:./data/data.db?_foreign_keys=on"),
}
dbConn, err := db.Init(dbCfg)
if err != nil { if err != nil {
log.Fatalf("init db: %v", err) log.Fatalf("init db: %v", err)
} }
authService, err := auth.NewAuthService(auth.Config{ if err := migrations.AutoMigrate(dbConn); err != nil {
Secret: cfg.Auth.Secret, log.Fatalf("migrate user: %v", err)
Issuer: cfg.Auth.Issuer,
AccessTokenExpiry: time.Duration(cfg.Auth.AccessTokenExpiryMinutes) * time.Minute,
RefreshTokenExpiry: time.Duration(cfg.Auth.RefreshTokenExpiryMinutes) * time.Minute,
})
if err != nil {
log.Fatalf("setup auth: %v", err)
} }
mailService, err := mail.New(mail.Config{ tokenService, err := tokens.GetTockenService()
AppName: cfg.AppName,
Mode: cfg.Mail.Mode,
From: cfg.Mail.From,
DebugDir: cfg.Mail.DebugDir,
TemplatesDir: cfg.Mail.TemplatesDir,
FrontendBaseURL: cfg.Mail.FrontendBaseURL,
ResetPasswordPath: cfg.Mail.ResetPasswordPath,
SMTP: mail.SMTPConfig{
Host: cfg.Mail.SMTP.Host,
Port: cfg.Mail.SMTP.Port,
Username: cfg.Mail.SMTP.Username,
Password: cfg.Mail.SMTP.Password,
},
})
if err != nil { if err != nil {
log.Fatalf("setup mail: %v", err) log.Fatalf("init tokens: %v", err)
} }
app := fiber.New(fiber.Config{ app := fiber.New(fiber.Config{
@ -125,9 +97,9 @@ func main() {
return c.Next() return c.Next()
}) })
app.Use(authorization.RequireEndpointPermission(authService, dbConn)) app.Use(roles.RequireEndpointPermission(dbConn, tokenService))
routes.Register(app, authService, mailService) routes.Register(app)
port := envOrDefault("PORT", "3000") port := envOrDefault("PORT", "3000")

View File

@ -1,22 +0,0 @@
{
"roles": {
"admin": ["admin", "manager", "user"],
"manager": ["manager", "user"],
"user": ["user"]
},
"permissions": {
"admin": [
"users:read",
"users:write",
"sessions:purge",
"admin:users:list"
],
"manager": [
"users:read"
],
"user": []
},
"endpoints": {
"POST /admin/users": "admin:users:list"
}
}

View File

@ -7,9 +7,9 @@ import (
"github.com/gofiber/fiber/v3" "github.com/gofiber/fiber/v3"
"gorm.io/gorm" "gorm.io/gorm"
"server/internal/helpers" "server/internal/db"
"server/internal/models"
"server/internal/responses" "server/internal/responses"
users "server/internal/user"
"server/internal/validation" "server/internal/validation"
) )
@ -46,12 +46,12 @@ func (ac *AdminController) ListUsers(c fiber.Ctx) error {
req.PageSize = 20 req.PageSize = 20
} }
db, err := helpers.DBFromCtx(c) db, err := db.DBFromCtx(c)
if err != nil { if err != nil {
return err return err
} }
var list []models.User var list []users.User
offset := (req.Page - 1) * req.PageSize offset := (req.Page - 1) * req.PageSize
if err := db.Preload("Details").Preload("Preferences"). if err := db.Preload("Details").Preload("Preferences").
Limit(req.PageSize). Limit(req.PageSize).
@ -60,17 +60,11 @@ func (ac *AdminController) ListUsers(c fiber.Ctx) error {
return fiber.NewError(fiber.StatusInternalServerError, "failed to load users") return fiber.NewError(fiber.StatusInternalServerError, "failed to load users")
} }
// Map to short representation
short := make([]models.UserShort, 0, len(list))
for i := range list {
short = append(short, models.ToUserShort(&list[i]))
}
return c.JSON(fiber.Map{ return c.JSON(fiber.Map{
"data": fiber.Map{ "data": fiber.Map{
"page": req.Page, "page": req.Page,
"pageSize": req.PageSize, "pageSize": req.PageSize,
"items": short, "items": list,
}, },
"error": nil, "error": nil,
}) })
@ -86,7 +80,7 @@ func (ac *AdminController) BlockUser(c fiber.Ctx) error {
return err return err
} }
db, err := helpers.DBFromCtx(c) db, err := db.DBFromCtx(c)
if err != nil { if err != nil {
return err return err
} }
@ -96,8 +90,8 @@ func (ac *AdminController) BlockUser(c fiber.Ctx) error {
return fiber.NewError(fiber.StatusBadRequest, "invalid user uuid") return fiber.NewError(fiber.StatusBadRequest, "invalid user uuid")
} }
var user models.User var u users.User
if err := db.Preload("Details").Preload("Preferences").Where("uuid = ?", uuid).First(&user).Error; err != nil { if err := db.Preload("Details").Preload("Preferences").Where("uuid = ?", uuid).First(&u).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusNotFound, "user not found") return fiber.NewError(fiber.StatusNotFound, "user not found")
} }
@ -106,17 +100,17 @@ func (ac *AdminController) BlockUser(c fiber.Ctx) error {
switch req.Action { switch req.Action {
case "block": case "block":
user.Status = models.UserStatusDisabled u.Status = users.UserStatusDisabled
case "unblock": case "unblock":
user.Status = models.UserStatusActive u.Status = users.UserStatusActive
default: default:
return fiber.NewError(fiber.StatusBadRequest, "invalid action") return fiber.NewError(fiber.StatusBadRequest, "invalid action")
} }
user.UpdatedAt = time.Now().UTC() u.UpdatedAt = time.Now().UTC()
if err := db.Save(&user).Error; err != nil { if err := db.Save(&u).Error; err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to update user status") return fiber.NewError(fiber.StatusInternalServerError, "failed to update user status")
} }
return c.JSON(responses.Success(models.ToUserShort(&user))) return c.JSON(responses.Success(u))
} }

View File

@ -1,7 +1,7 @@
package admin package admin
import ( import (
"server/internal/authorization" "server/internal/roles"
"github.com/gofiber/fiber/v3" "github.com/gofiber/fiber/v3"
) )
@ -11,9 +11,9 @@ func RegisterAdminRoutes(app *fiber.App) {
// Typescript: TSEndpoint= path=/admin/users; name=listUsers; method=POST; request=admin.ListUsersRequest; response=models.[]UserShort // Typescript: TSEndpoint= path=/admin/users; name=listUsers; method=POST; request=admin.ListUsersRequest; response=models.[]UserShort
app.Post("/admin/users", adminController.ListUsers) app.Post("/admin/users", adminController.ListUsers)
authorization.RegisterEndpoint("POST/admin/users", int(authorization.AdminPermission)) roles.RegisterEndpoint("POST/admin/users", int(roles.AdminPermission))
// Typescript: TSEndpoint= path=/admin/users/:uuid/block; name=blockUser; method=PUT; request=admin.BlockUserRequest; response=models.UserShort // Typescript: TSEndpoint= path=/admin/users/:uuid/block; name=blockUser; method=PUT; request=admin.BlockUserRequest; response=models.UserShort
app.Put("/admin/users/:uuid/block", adminController.BlockUser) app.Put("/admin/users/:uuid/block", adminController.BlockUser)
authorization.RegisterEndpoint("PUT/admin/users/:uuid/block", int(authorization.AdminPermission)) roles.RegisterEndpoint("PUT/admin/users/:uuid/block", int(roles.AdminPermission))
} }

View File

@ -1,380 +0,0 @@
package auth
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"server/internal/helpers"
"server/internal/mail"
"server/internal/models"
"server/internal/responses"
"server/internal/tokens"
"server/internal/validation"
"strings"
"time"
"github.com/gofiber/fiber/v3"
"github.com/google/uuid"
"gorm.io/gorm"
)
type AuthController struct {
authService *AuthService
mailService *mail.Service
}
func New(authService *AuthService, mailService *mail.Service) *AuthController {
return &AuthController{
authService: authService,
mailService: mailService,
}
}
// Login authenticates a user and issues an access/refresh token pair.
func (ac *AuthController) Login(c fiber.Ctx) error {
var req LoginRequest
if err := c.Bind().Body(&req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
}
if err := validation.ValidateStruct(&req); err != nil {
return err
}
db, err := helpers.DBFromCtx(c)
if err != nil {
return err
}
var user models.User
if err := db.Where("email = ?", req.Username).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusUnauthorized, "invalid credentials")
}
return fiber.NewError(fiber.StatusInternalServerError, "failed to fetch user")
}
match, err := VerifyPassword(user.Password, req.Password)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to verify credentials")
}
if !match {
return fiber.NewError(fiber.StatusUnauthorized, "invalid credentials")
}
token, err := ac.authService.GenerateTokenPair(user.Email)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to issue token")
}
userID := user.ID
now := time.Now().UTC()
if err := db.Where("expires_at < ?", now).Delete(&models.Session{}).Error; err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to purge expired sessions")
}
session := models.Session{
UserID: &userID,
Username: user.Email,
AccessTokenHash: tokens.HashToken(token.AccessToken),
RefreshTokenHash: tokens.HashToken(token.RefreshToken),
ExpiresAt: now.Add(ac.authService.RefreshExpiry()),
IPAddress: c.IP(),
UserAgent: c.Get("User-Agent"),
}
if err := db.Create(&session).Error; err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to record session")
}
c.Response().Header.Set("Auth-Token", token.AccessToken)
return c.JSON(responses.Success(token))
}
// Refresh renews an access/refresh token pair using a valid refresh token.
func (ac *AuthController) Refresh(c fiber.Ctx) error {
var req RefreshRequest
if err := c.Bind().Body(&req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
}
if req.RefreshToken == "" {
return fiber.NewError(fiber.StatusBadRequest, "refresh_token is required")
}
tokens, err := ac.authService.Refresh(req.RefreshToken)
if err != nil {
return fiber.NewError(fiber.StatusUnauthorized, err.Error())
}
return c.JSON(responses.Success(tokens))
}
// Register creates a new user with optional roles/types/preferences.
func (ac *AuthController) Register(c fiber.Ctx) error {
var req models.UserCreateInput
if err := c.Bind().Body(&req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
}
if err := validation.ValidateStruct(&req); err != nil {
return err
}
db, err := helpers.DBFromCtx(c)
if err != nil {
return err
}
var existing models.User
if err := db.Where("email = ?", req.Email).First(&existing).Error; err == nil {
return fiber.NewError(fiber.StatusConflict, "user already exists")
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusInternalServerError, "failed to check user")
}
now := time.Now().UTC()
hashedPassword, err := HashPassword(req.Password)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to secure password")
}
user := models.User{
Email: req.Email,
Name: req.Name,
Password: hashedPassword,
Roles: func() models.UserRoles {
if len(req.Roles) == 0 {
return models.UserRoles{"user"}
}
return req.Roles
}(),
Status: func() models.UserStatus {
if req.Status == "" {
return models.UserStatusPending
}
return req.Status
}(),
Types: func() models.UserTypes {
if len(req.Types) == 0 {
return models.UserTypes{"internal"}
}
return req.Types
}(),
Avatar: req.Avatar,
UUID: uuid.NewString(),
Details: helpers.ToUserDetails(req.Details),
Preferences: func() *models.UserPreferences {
if req.Preferences == nil {
return nil
}
return &models.UserPreferences{
UseIdle: req.Preferences.UseIdle,
IdleTimeout: req.Preferences.IdleTimeout,
UseIdlePassword: req.Preferences.UseIdlePassword,
IdlePin: req.Preferences.IdlePin,
UseDirectLogin: req.Preferences.UseDirectLogin,
UseQuadcodeLogin: req.Preferences.UseQuadcodeLogin,
SendNoticesMail: req.Preferences.SendNoticesMail,
Language: req.Preferences.Language,
}
}(),
CreatedAt: now,
UpdatedAt: now,
}
if err := db.Create(&user).Error; err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to create user")
}
if err := ac.mailService.Send(c, mail.Message{
To: user.Email,
Subject: fmt.Sprintf("[%s] Registrazione completata", ac.mailService.AppName()),
Template: "registration",
TemplateData: mail.TemplateData{
AppName: ac.mailService.AppName(),
UserName: user.Name,
UserEmail: user.Email,
},
}); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to send registration email")
}
return c.Status(fiber.StatusCreated).JSON(responses.Success(models.ToUserShort(&user)))
}
func (ac *AuthController) ForgotPassword(c fiber.Ctx) error {
var req ForgotPasswordRequest
if err := c.Bind().Body(&req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
}
if err := validation.ValidateStruct(&req); err != nil {
return err
}
db, err := helpers.DBFromCtx(c)
if err != nil {
return err
}
var user models.User
if err := db.Where("email = ?", req.Email).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return c.JSON(responses.Success(fiber.Map{"sent": true}))
}
return fiber.NewError(fiber.StatusInternalServerError, "failed to load user")
}
if user.Status == models.UserStatusDisabled {
return c.JSON(responses.Success(fiber.Map{"sent": true}))
}
resetToken, err := generateSecureToken()
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to generate reset token")
}
now := time.Now().UTC()
record := models.PasswordResetToken{
UserID: user.ID,
TokenHash: tokens.HashToken(resetToken),
ExpiresAt: now.Add(30 * time.Minute),
CreatedAt: now,
UpdatedAt: now,
}
if err := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("user_id = ? OR expires_at < ? OR used_at IS NOT NULL", user.ID, now).
Delete(&models.PasswordResetToken{}).Error; err != nil {
return err
}
return tx.Create(&record).Error
}); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to store reset token")
}
if err := ac.mailService.Send(c, mail.Message{
To: user.Email,
Subject: fmt.Sprintf("[%s] Recupero password", ac.mailService.AppName()),
Template: "password_reset",
TemplateData: mail.TemplateData{
AppName: ac.mailService.AppName(),
UserName: user.Name,
UserEmail: user.Email,
ResetToken: resetToken,
ResetURL: ac.mailService.ResetLink(resetToken),
},
}); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to send reset email")
}
return c.JSON(responses.Success(responses.SimpleResponse{Message: "password reset email sent"}))
}
func (ac *AuthController) ResetPassword(c fiber.Ctx) error {
var req ResetPasswordRequest
if err := c.Bind().Body(&req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
}
if err := validation.ValidateStruct(&req); err != nil {
return err
}
db, err := helpers.DBFromCtx(c)
if err != nil {
return err
}
hashedPassword, err := HashPassword(req.Password)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to secure password")
}
now := time.Now().UTC()
tokenHash := tokens.HashToken(req.Token)
if err := db.Transaction(func(tx *gorm.DB) error {
var resetToken models.PasswordResetToken
if err := tx.Where("token_hash = ?", tokenHash).First(&resetToken).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusBadRequest, "invalid or expired reset token")
}
return err
}
if resetToken.UsedAt != nil || now.After(resetToken.ExpiresAt) {
return fiber.NewError(fiber.StatusBadRequest, "invalid or expired reset token")
}
if err := tx.Model(&models.User{}).Where("id = ?", resetToken.UserID).Updates(map[string]any{
"password": hashedPassword,
"updated_at": now,
}).Error; err != nil {
return err
}
if err := tx.Model(&resetToken).Updates(map[string]any{
"used_at": now,
"updated_at": now,
}).Error; err != nil {
return err
}
if err := tx.Where("user_id = ?", resetToken.UserID).Delete(&models.Session{}).Error; err != nil {
return err
}
if err := tx.Where("user_id = ? AND id <> ?", resetToken.UserID, resetToken.ID).Delete(&models.PasswordResetToken{}).Error; err != nil {
return err
}
return nil
}); err != nil {
var fiberErr *fiber.Error
if errors.As(err, &fiberErr) {
return fiberErr
}
return fiber.NewError(fiber.StatusInternalServerError, "failed to reset password")
}
return c.JSON(responses.Success(responses.SimpleResponse{Message: "password reset successful"}))
}
func (ac *AuthController) ValidToken(c fiber.Ctx) error {
raw := strings.TrimSpace(string(c.Body()))
if raw == "" {
return fiber.NewError(fiber.StatusBadRequest, "token is required")
}
token := raw
if strings.HasPrefix(raw, "\"") && strings.HasSuffix(raw, "\"") {
if err := json.Unmarshal([]byte(raw), &token); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
}
}
token = strings.TrimSpace(token)
if token == "" {
return fiber.NewError(fiber.StatusBadRequest, "token is required")
}
db, err := helpers.DBFromCtx(c)
if err != nil {
return err
}
now := time.Now().UTC()
tokenHash := tokens.HashToken(token)
var resetToken models.PasswordResetToken
if err := db.Where("token_hash = ?", tokenHash).First(&resetToken).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusBadRequest, "invalid or expired reset token")
}
return fiber.NewError(fiber.StatusInternalServerError, "failed to validate reset token")
}
if resetToken.UsedAt != nil || now.After(resetToken.ExpiresAt) {
return fiber.NewError(fiber.StatusBadRequest, "invalid or expired reset token")
}
return c.JSON(responses.Success(responses.SimpleResponse{Message: "valid reset token"}))
}
func generateSecureToken() (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}

View File

@ -1,26 +0,0 @@
package auth
import (
"time"
"github.com/golang-jwt/jwt/v5"
)
type Config struct {
Secret string
Issuer string
AccessTokenExpiry time.Duration
RefreshTokenExpiry time.Duration
}
type Claims struct {
Username string `json:"username"`
TokenType string `json:"type"`
jwt.RegisteredClaims
}
// Typescript: interface
type TokenPair struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
}

View File

@ -1,23 +0,0 @@
package auth
// Typescript: interface
type LoginRequest struct {
Username string `json:"username" validate:"required,email"`
Password string `json:"password" validate:"required,min=8,max=128"`
}
// Typescript: interface
type RefreshRequest struct {
RefreshToken string `json:"refresh_token"`
}
// Typescript: interface
type ForgotPasswordRequest struct {
Email string `json:"email" validate:"required,email"`
}
// Typescript: interface
type ResetPasswordRequest struct {
Token string `json:"token" validate:"required,min=20,max=255"`
Password string `json:"password" validate:"required,min=8,max=128"`
}

View File

@ -1,37 +0,0 @@
package auth
import (
"time"
"server/internal/mail"
"github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/middleware/limiter"
)
func Register(app *fiber.App, authService *AuthService, mailService *mail.Service) {
authController := New(authService, mailService)
authRateLimiter := limiter.New(limiter.Config{
Max: 10,
Expiration: time.Minute,
LimiterMiddleware: limiter.SlidingWindow{},
})
// Typescript: TSEndpoint= path=/auth/login; name=login; method=POST; request=model.LoginRequest; response=model.TokenPair
app.Post("/auth/login", authRateLimiter, authController.Login)
// Typescript: TSEndpoint= path=/auth/refresh; name=refresh; method=POST; request=model.RefreshRequest; response=model.TokenPair
app.Post("/auth/refresh", authService.Middleware(), authController.Refresh)
// Typescript: TSEndpoint= path=/auth/register; name=register; method=POST; request=models.UserCreateInput; response=models.UserShort
app.Post("/auth/register", authRateLimiter, authController.Register)
// Typescript: TSEndpoint= path=/auth/password/forgot; name=forgotPassword; method=POST; request=model.ForgotPasswordRequest; response=controllers.SimpleResponse
app.Post("/auth/password/forgot", authRateLimiter, authController.ForgotPassword)
// Typescript: TSEndpoint= path=/auth/password/reset; name=resetPassword; method=POST; request=model.ResetPasswordRequest; response=controllers.SimpleResponse
app.Post("/auth/password/reset", authRateLimiter, authController.ResetPassword)
// Typescript: TSEndpoint= path=/auth/password/valid; name=validToken; method=POST; request=string; response=controllers.SimpleResponse
app.Post("/auth/password/valid", authRateLimiter, authController.ValidToken)
}

View File

@ -1,149 +0,0 @@
package auth
import (
"errors"
"time"
"github.com/gofiber/fiber/v3"
"github.com/golang-jwt/jwt/v5"
)
type AuthService struct {
cfg Config
secret []byte
accessExpiry time.Duration
refreshExpiry time.Duration
}
const (
tokenTypeAccess = "access"
tokenTypeRefresh = "refresh"
)
func NewAuthService(cfg Config) (*AuthService, error) {
if cfg.Secret == "" {
return nil, errors.New("jwt secret is required")
}
if cfg.AccessTokenExpiry <= 0 {
return nil, errors.New("access token expiry must be positive")
}
if cfg.RefreshTokenExpiry <= 0 {
return nil, errors.New("refresh token expiry must be positive")
}
return &AuthService{
cfg: cfg,
secret: []byte(cfg.Secret),
accessExpiry: cfg.AccessTokenExpiry,
refreshExpiry: cfg.RefreshTokenExpiry,
}, nil
}
func (s *AuthService) GenerateTokenPair(username string) (TokenPair, error) {
access, err := s.generateToken(username, tokenTypeAccess, s.accessExpiry)
if err != nil {
return TokenPair{}, err
}
refresh, err := s.generateToken(username, tokenTypeRefresh, s.refreshExpiry)
if err != nil {
return TokenPair{}, err
}
return TokenPair{
AccessToken: access,
RefreshToken: refresh,
}, nil
}
func (s *AuthService) AccessExpiry() time.Duration {
return s.accessExpiry
}
func (s *AuthService) RefreshExpiry() time.Duration {
return s.refreshExpiry
}
func (s *AuthService) Middleware() fiber.Handler {
return func(c fiber.Ctx) error {
tokenString := c.Get("Auth-Token")
if tokenString == "" {
return fiber.NewError(fiber.StatusUnauthorized, "missing token header")
}
claims, err := s.parseToken(tokenString)
if err != nil {
return fiber.NewError(fiber.StatusUnauthorized, err.Error())
}
if claims.TokenType != tokenTypeAccess {
return fiber.NewError(fiber.StatusUnauthorized, "access token required")
}
c.Locals("authClaims", claims)
return c.Next()
}
}
func (s *AuthService) Refresh(refreshToken string) (TokenPair, error) {
claims, err := s.parseToken(refreshToken)
if err != nil {
return TokenPair{}, err
}
if claims.TokenType != tokenTypeRefresh {
return TokenPair{}, errors.New("refresh token required")
}
return s.GenerateTokenPair(claims.Username)
}
func (s *AuthService) ValidateAccessToken(tokenString string) (*Claims, error) {
claims, err := s.parseToken(tokenString)
if err != nil {
return nil, err
}
if claims.TokenType != tokenTypeAccess {
return nil, errors.New("access token required")
}
return claims, nil
}
func ClaimsFromCtx(c fiber.Ctx) (*Claims, bool) {
val := c.Locals("authClaims")
if val == nil {
return nil, false
}
claims, ok := val.(*Claims)
return claims, ok
}
func (s *AuthService) parseToken(tokenString string) (*Claims, error) {
claims := &Claims{}
token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fiber.ErrUnauthorized
}
return s.secret, nil
})
if err != nil || !token.Valid {
return nil, errors.New("invalid or expired token")
}
if s.cfg.Issuer != "" && claims.Issuer != "" && claims.Issuer != s.cfg.Issuer {
return nil, errors.New("invalid token issuer")
}
return claims, nil
}
func (s *AuthService) generateToken(username, tokenType string, expiry time.Duration) (string, error) {
claims := Claims{
Username: username,
TokenType: tokenType,
RegisteredClaims: jwt.RegisteredClaims{
Issuer: s.cfg.Issuer,
ExpiresAt: jwt.NewNumericDate(time.Now().Add(expiry)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(s.secret)
}

View File

@ -14,6 +14,7 @@ type ServerConfig struct {
DisableStartupMessage bool `json:"disable_startup_message"` DisableStartupMessage bool `json:"disable_startup_message"`
Auth AuthConfig `json:"auth"` Auth AuthConfig `json:"auth"`
Mail MailConfig `json:"mail"` Mail MailConfig `json:"mail"`
Db DbConfig `json:"db_config"`
RolesConfigPath string `json:"roles_config_path"` RolesConfigPath string `json:"roles_config_path"`
} }
@ -42,23 +43,54 @@ type SMTPMailConfig struct {
Password string `json:"password"` Password string `json:"password"`
} }
func LoadConfig(path string) (ServerConfig, error) { type DbConfig struct {
Driver string
DSN string
}
var Config *ServerConfig = nil
func GetConfig() (*ServerConfig, error) {
if Config == nil {
var err error
Config, err = loadConfig()
if err != nil {
fmt.Printf("Failed to load config: %v\n", err)
return nil, err
}
}
return Config, nil
}
func envOrDefault(key, defaultValue string) string {
if value, exists := os.LookupEnv(key); exists {
return value
}
return defaultValue
}
func loadConfig() (*ServerConfig, error) {
path := envOrDefault("CONFIG_PATH", "configs/config.json")
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {
return ServerConfig{}, fmt.Errorf("read config: %w", err) return nil, fmt.Errorf("read config: %w", err)
} }
var cfg ServerConfig var cfg ServerConfig
if err := json.Unmarshal(data, &cfg); err != nil { if err := json.Unmarshal(data, &cfg); err != nil {
return ServerConfig{}, fmt.Errorf("parse config: %w", err) return nil, fmt.Errorf("parse config: %w", err)
}
if secret := os.Getenv("AUTH_SECRET"); secret != "" {
cfg.Auth.Secret = secret
} }
if cfg.Auth.Secret == "" { if cfg.Auth.Secret == "" {
return ServerConfig{}, fmt.Errorf("auth.secret must be set") return nil, fmt.Errorf("auth.secret must be set")
} }
if cfg.Auth.AccessTokenExpiryMinutes <= 0 { if cfg.Auth.AccessTokenExpiryMinutes <= 0 {
return ServerConfig{}, fmt.Errorf("auth.access_token_expiry_minutes must be greater than zero") return nil, fmt.Errorf("auth.access_token_expiry_minutes must be greater than zero")
} }
if cfg.Auth.RefreshTokenExpiryMinutes <= 0 { if cfg.Auth.RefreshTokenExpiryMinutes <= 0 {
return ServerConfig{}, fmt.Errorf("auth.refresh_token_expiry_minutes must be greater than zero") return nil, fmt.Errorf("auth.refresh_token_expiry_minutes must be greater than zero")
} }
if cfg.Mail.Mode == "" { if cfg.Mail.Mode == "" {
cfg.Mail.Mode = "file" cfg.Mail.Mode = "file"
@ -70,21 +102,26 @@ func LoadConfig(path string) (ServerConfig, error) {
cfg.Mail.ResetPasswordPath = "/#reset-password" cfg.Mail.ResetPasswordPath = "/#reset-password"
} }
if cfg.Mail.Mode != "smtp" && cfg.Mail.Mode != "file" { if cfg.Mail.Mode != "smtp" && cfg.Mail.Mode != "file" {
return ServerConfig{}, fmt.Errorf("mail.mode must be either smtp or file") return nil, fmt.Errorf("mail.mode must be either smtp or file")
} }
if cfg.Mail.From == "" { if cfg.Mail.From == "" {
return ServerConfig{}, fmt.Errorf("mail.from must be set") return nil, fmt.Errorf("mail.from must be set")
} }
if cfg.Mail.Mode == "smtp" { if cfg.Mail.Mode == "smtp" {
if cfg.Mail.SMTP.Host == "" { if cfg.Mail.SMTP.Host == "" {
return ServerConfig{}, fmt.Errorf("mail.smtp.host must be set when mail.mode=smtp") return nil, fmt.Errorf("mail.smtp.host must be set when mail.mode=smtp")
} }
if cfg.Mail.SMTP.Port <= 0 { if cfg.Mail.SMTP.Port <= 0 {
return ServerConfig{}, fmt.Errorf("mail.smtp.port must be greater than zero when mail.mode=smtp") return nil, fmt.Errorf("mail.smtp.port must be greater than zero when mail.mode=smtp")
} }
} else if cfg.Mail.DebugDir == "" { } else if cfg.Mail.DebugDir == "" {
cfg.Mail.DebugDir = "data/mail-debug" cfg.Mail.DebugDir = "data/mail-debug"
} }
return cfg, nil cfg.Db = DbConfig{
Driver: envOrDefault("DB_driver", "sqlite"),
DSN: envOrDefault("DB_dsn", "file:./data/data.db?_foreign_keys=on"),
}
return &cfg, nil
} }

View File

@ -4,44 +4,51 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"server/internal/config"
"strings" "strings"
"server/internal/models" "github.com/gofiber/fiber/v3"
"gorm.io/driver/postgres" "gorm.io/driver/postgres"
"gorm.io/driver/sqlite" "gorm.io/driver/sqlite"
"gorm.io/gorm" "gorm.io/gorm"
) )
type Config struct { var DB *gorm.DB
Driver string
DSN string // GetDB returns the global *gorm.DB instance. It panics if the database is not initialized.
func GetDB() (*gorm.DB, error) {
if DB == nil {
cfg, err := config.GetConfig()
if err != nil {
return nil, err
}
DB, err = InitDB(cfg.Db)
if err != nil {
fmt.Printf("Failed to initialize database: %v\n", err)
return nil, err
}
}
return DB, nil
} }
// Init opens the configured database connection and runs schema migrations. // Init opens the configured database connection and runs schema migrations.
func Init(cfg Config) (*gorm.DB, error) { func InitDB(cfg config.DbConfig) (*gorm.DB, error) {
switch cfg.Driver { switch cfg.Driver {
case "sqlite": case "sqlite":
if err := ensureSQLiteDir(cfg.DSN); err != nil { if err := ensureSQLiteDir(cfg.DSN); err != nil {
return nil, fmt.Errorf("prepare sqlite path: %w", err) return nil, fmt.Errorf("prepare sqlite path: %w", err)
} }
db, err := gorm.Open(sqlite.Open(cfg.DSN), &gorm.Config{}) DB, err := gorm.Open(sqlite.Open(cfg.DSN), &gorm.Config{})
if err != nil { if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err) return nil, fmt.Errorf("open sqlite: %w", err)
} }
if err := db.AutoMigrate(&models.User{}, &models.UserDetails{}, &models.UserPreferences{}, &models.Session{}, &models.PasswordResetToken{}); err != nil { return DB, nil
return nil, fmt.Errorf("migrate user: %w", err)
}
return db, nil
case "postgres": case "postgres":
db, err := gorm.Open(postgres.Open(cfg.DSN), &gorm.Config{}) DB, err := gorm.Open(postgres.Open(cfg.DSN), &gorm.Config{})
if err != nil { if err != nil {
return nil, fmt.Errorf("open postgres: %w", err) return nil, fmt.Errorf("open postgres: %w", err)
} }
if err := db.AutoMigrate(&models.User{}, &models.UserDetails{}, &models.UserPreferences{}, &models.Session{}, &models.PasswordResetToken{}); err != nil { return DB, nil
return nil, fmt.Errorf("migrate user: %w", err)
}
return db, nil
default: default:
return nil, fmt.Errorf("unsupported driver %q", cfg.Driver) return nil, fmt.Errorf("unsupported driver %q", cfg.Driver)
} }
@ -62,3 +69,17 @@ func ensureSQLiteDir(dsn string) error {
} }
return os.MkdirAll(dir, 0o755) return os.MkdirAll(dir, 0o755)
} }
// dbFromCtx extracts *gorm.DB from Fiber context.
func dbFromCtx(c fiber.Ctx) (*gorm.DB, error) {
dbVal := c.Locals("db")
db, ok := dbVal.(*gorm.DB)
if !ok || db == nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "database unavailable")
}
return db, nil
}
func DBFromCtx(c fiber.Ctx) (*gorm.DB, error) {
return dbFromCtx(c)
}

View File

@ -1,62 +0,0 @@
package helpers
import (
"server/internal/models"
"github.com/gofiber/fiber/v3"
"gorm.io/gorm"
)
// dbFromCtx extracts *gorm.DB from Fiber context.
func dbFromCtx(c fiber.Ctx) (*gorm.DB, error) {
dbVal := c.Locals("db")
db, ok := dbVal.(*gorm.DB)
if !ok || db == nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "database unavailable")
}
return db, nil
}
func DBFromCtx(c fiber.Ctx) (*gorm.DB, error) {
return dbFromCtx(c)
}
func toUserDetails(d *models.UserDetailsShort) *models.UserDetails {
if d == nil {
return nil
}
return &models.UserDetails{
Title: d.Title,
FirstName: d.FirstName,
LastName: d.LastName,
Address: d.Address,
City: d.City,
ZipCode: d.ZipCode,
Country: d.Country,
Phone: d.Phone,
}
}
func ToUserDetails(d *models.UserDetailsShort) *models.UserDetails {
return toUserDetails(d)
}
func toUserPreferences(p *models.UserPreferencesShort) *models.UserPreferences {
if p == nil {
return nil
}
return &models.UserPreferences{
UseIdle: p.UseIdle,
IdleTimeout: p.IdleTimeout,
UseIdlePassword: p.UseIdlePassword,
IdlePin: p.IdlePin,
UseDirectLogin: p.UseDirectLogin,
UseQuadcodeLogin: p.UseQuadcodeLogin,
SendNoticesMail: p.SendNoticesMail,
Language: p.Language,
}
}
func ToUserPreferences(p *models.UserPreferencesShort) *models.UserPreferences {
return toUserPreferences(p)
}

View File

@ -10,6 +10,7 @@ import (
"net/smtp" "net/smtp"
"os" "os"
"path/filepath" "path/filepath"
"server/internal/config"
"strings" "strings"
texttemplate "text/template" texttemplate "text/template"
"time" "time"
@ -40,7 +41,7 @@ type Message struct {
TemplateData any TemplateData any
} }
type Service struct { type MailService struct {
cfg Config cfg Config
} }
@ -52,7 +53,30 @@ type TemplateData struct {
ResetToken string ResetToken string
} }
func New(cfg Config) (*Service, error) { // if service fail send admin allert instead a response to user or a simple response server error.
func New() (*MailService, error) {
serverCfg, err := config.GetConfig()
if err != nil {
return nil, err
}
cfg := Config{
AppName: serverCfg.AppName,
Mode: serverCfg.Mail.Mode,
From: serverCfg.Mail.From,
DebugDir: serverCfg.Mail.DebugDir,
TemplatesDir: serverCfg.Mail.TemplatesDir,
FrontendBaseURL: serverCfg.Mail.FrontendBaseURL,
ResetPasswordPath: serverCfg.Mail.ResetPasswordPath,
SMTP: SMTPConfig{
Host: serverCfg.Mail.SMTP.Host,
Port: serverCfg.Mail.SMTP.Port,
Username: serverCfg.Mail.SMTP.Username,
Password: serverCfg.Mail.SMTP.Password,
},
}
if cfg.Mode != "smtp" && cfg.Mode != "file" { if cfg.Mode != "smtp" && cfg.Mode != "file" {
return nil, fmt.Errorf("unsupported mail mode %q", cfg.Mode) return nil, fmt.Errorf("unsupported mail mode %q", cfg.Mode)
} }
@ -78,10 +102,10 @@ func New(cfg Config) (*Service, error) {
return nil, fmt.Errorf("smtp host and port are required") return nil, fmt.Errorf("smtp host and port are required")
} }
} }
return &Service{cfg: cfg}, nil return &MailService{cfg: cfg}, nil
} }
func (s *Service) Send(ctx context.Context, msg Message) error { func (s *MailService) Send(ctx context.Context, msg Message) error {
htmlBody, textBody, err := s.renderBodies(msg.Template, msg.TemplateData) htmlBody, textBody, err := s.renderBodies(msg.Template, msg.TemplateData)
if err != nil { if err != nil {
return err return err
@ -98,7 +122,7 @@ func (s *Service) Send(ctx context.Context, msg Message) error {
} }
} }
func (s *Service) ResetLink(token string) string { func (s *MailService) ResetLink(token string) string {
base := strings.TrimRight(s.cfg.FrontendBaseURL, "/") base := strings.TrimRight(s.cfg.FrontendBaseURL, "/")
path := s.cfg.ResetPasswordPath path := s.cfg.ResetPasswordPath
if path == "" { if path == "" {
@ -113,11 +137,11 @@ func (s *Service) ResetLink(token string) string {
return base + path + "?token=" + token return base + path + "?token=" + token
} }
func (s *Service) AppName() string { func (s *MailService) AppName() string {
return s.cfg.AppName return s.cfg.AppName
} }
func (s *Service) renderBodies(templateName string, data any) (string, string, error) { func (s *MailService) renderBodies(templateName string, data any) (string, string, error) {
htmlPath := filepath.Join(s.cfg.TemplatesDir, templateName+".html.tmpl") htmlPath := filepath.Join(s.cfg.TemplatesDir, templateName+".html.tmpl")
textPath := filepath.Join(s.cfg.TemplatesDir, templateName+".txt.tmpl") textPath := filepath.Join(s.cfg.TemplatesDir, templateName+".txt.tmpl")
@ -171,7 +195,7 @@ func buildMessage(from, to, subject, textBody, htmlBody string) []byte {
return []byte(strings.Join(append(headers, body...), "\r\n")) return []byte(strings.Join(append(headers, body...), "\r\n"))
} }
func (s *Service) sendSMTP(ctx context.Context, to string, raw []byte) error { func (s *MailService) sendSMTP(ctx context.Context, to string, raw []byte) error {
addr := fmt.Sprintf("%s:%d", s.cfg.SMTP.Host, s.cfg.SMTP.Port) addr := fmt.Sprintf("%s:%d", s.cfg.SMTP.Host, s.cfg.SMTP.Port)
dialer := &net.Dialer{} dialer := &net.Dialer{}
conn, err := dialer.DialContext(ctx, "tcp", addr) conn, err := dialer.DialContext(ctx, "tcp", addr)
@ -221,7 +245,7 @@ func (s *Service) sendSMTP(ctx context.Context, to string, raw []byte) error {
return nil return nil
} }
func (s *Service) writeDebugMail(to, subject string, raw []byte) error { func (s *MailService) writeDebugMail(to, subject string, raw []byte) error {
safeRecipient := strings.NewReplacer("@", "_at_", "/", "_", "\\", "_", ":", "_", " ", "_").Replace(to) safeRecipient := strings.NewReplacer("@", "_at_", "/", "_", "\\", "_", ":", "_", " ", "_").Replace(to)
filename := fmt.Sprintf("%d_%s.eml", time.Now().UnixNano(), safeRecipient) filename := fmt.Sprintf("%d_%s.eml", time.Now().UnixNano(), safeRecipient)
path := filepath.Join(s.cfg.DebugDir, filename) path := filepath.Join(s.cfg.DebugDir, filename)

View File

@ -0,0 +1,15 @@
package migrations
import (
"fmt"
users "server/internal/user"
"gorm.io/gorm"
)
func AutoMigrate(db *gorm.DB) error {
if err := db.AutoMigrate(&users.User{}, &users.UserDetails{}, &users.UserPreferences{}, &users.Session{}, &users.PasswordResetToken{}); err != nil {
return fmt.Errorf("migrate user: %w", err)
}
return nil
}

View File

@ -8,13 +8,9 @@ type SimpleResponse struct {
} }
// success wraps a payload in the standard API envelope. // success wraps a payload in the standard API envelope.
func success(data any) fiber.Map { func Success(data any) fiber.Map {
return fiber.Map{ return fiber.Map{
"data": data, "data": data,
"error": nil, "error": nil,
} }
} }
func Success(data any) fiber.Map {
return success(data)
}

View File

@ -1,18 +1,14 @@
package authorization package roles
import ( import (
"errors"
"fmt" "fmt"
"server/internal/auth" "server/internal/tokens"
"server/internal/models"
"strings" "strings"
"github.com/gofiber/fiber/v3" "github.com/gofiber/fiber/v3"
"gorm.io/gorm" "gorm.io/gorm"
) )
var Endpoints map[string]int
type Permission int type Permission int
type Role struct { type Role struct {
@ -38,6 +34,8 @@ var Roles = []Role{
{"guest", GuestPermission}, {"guest", GuestPermission},
} }
var Endpoints map[string]int
func init() { func init() {
Endpoints = make(map[string]int) Endpoints = make(map[string]int)
} }
@ -48,7 +46,7 @@ func RegisterEndpoint(key string, permission int) {
// RequireEndpointPermission enforces permission mapping defined in role config. // RequireEndpointPermission enforces permission mapping defined in role config.
// If the endpoint is not configured, or mapped to "*", it allows the request. // If the endpoint is not configured, or mapped to "*", it allows the request.
func RequireEndpointPermission(authService *auth.AuthService, dbConn *gorm.DB) fiber.Handler { func RequireEndpointPermission(dbConn *gorm.DB, tokenService *tokens.TockenService) fiber.Handler {
return func(c fiber.Ctx) error { return func(c fiber.Ctx) error {
fmt.Printf("Checking permissions for %s%s\n", strings.TrimSpace(c.Method()), strings.TrimSpace(c.Path())) fmt.Printf("Checking permissions for %s%s\n", strings.TrimSpace(c.Method()), strings.TrimSpace(c.Path()))
perm := Endpoints[strings.TrimSpace(c.Method())+strings.TrimSpace(c.Path())] perm := Endpoints[strings.TrimSpace(c.Method())+strings.TrimSpace(c.Path())]
@ -61,22 +59,14 @@ func RequireEndpointPermission(authService *auth.AuthService, dbConn *gorm.DB) f
return fiber.NewError(fiber.StatusUnauthorized, "missing token header") return fiber.NewError(fiber.StatusUnauthorized, "missing token header")
} }
claims, err := authService.ValidateAccessToken(tokenString) claims, err := tokenService.ValidateAccessToken(tokenString)
if err != nil { if err != nil {
return fiber.NewError(fiber.StatusUnauthorized, err.Error()) return fiber.NewError(fiber.StatusUnauthorized, err.Error())
} }
c.Locals("authClaims", claims) c.Locals("authClaims", claims)
var user models.User
if err := dbConn.Select("roles").Where("email = ?", claims.Username).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusUnauthorized, "user not found")
}
return fiber.NewError(fiber.StatusInternalServerError, "failed to load user roles")
}
// user need to have at least one role that satisfies the permission requirement // user need to have at least one role that satisfies the permission requirement
if user.Roles == nil { if claims.Role == "" {
return fiber.NewError(fiber.StatusForbidden, "insufficient permissions") return fiber.NewError(fiber.StatusForbidden, "insufficient permissions")
} }

View File

@ -2,28 +2,14 @@ package routes
import ( import (
"server/internal/admin" "server/internal/admin"
"server/internal/auth"
"server/internal/mail"
"server/internal/systemUtils" "server/internal/systemUtils"
"server/internal/user" users "server/internal/user"
"github.com/gofiber/fiber/v3" "github.com/gofiber/fiber/v3"
) )
// Typescript: interface func Register(app *fiber.App) {
type FormRequest struct {
Req string `json:"req"`
Count int `json:"count"`
}
// Typescript: interface
type FormResponse struct {
Test string `json:"test"`
}
func Register(app *fiber.App, authService *auth.AuthService, mailService *mail.Service) {
systemUtils.RegisterSystemRoutes(app) systemUtils.RegisterSystemRoutes(app)
auth.Register(app, authService, mailService) users.RegisterUserRoutes(app)
user.RegisterUserRoutes(app, authService)
admin.RegisterAdminRoutes(app) admin.RegisterAdminRoutes(app)
} }

View File

@ -4,13 +4,14 @@ import (
"crypto/rand" "crypto/rand"
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"time" "time"
"github.com/brianvoe/gofakeit/v6" "github.com/brianvoe/gofakeit/v6"
"gorm.io/gorm" "gorm.io/gorm"
"server/internal/auth" "server/internal/systemUtils"
"server/internal/models" users "server/internal/user"
) )
// Credential exposes the plaintext password generated for a seeded user. // Credential exposes the plaintext password generated for a seeded user.
@ -20,14 +21,14 @@ type Credential struct {
} }
// SeedUsers generates n fake users and persists them. Returns the created slice. // SeedUsers generates n fake users and persists them. Returns the created slice.
func SeedUsers(db *gorm.DB, n int) ([]models.User, []Credential, error) { func SeedUsers(db *gorm.DB, n int) ([]users.User, []Credential, error) {
if n <= 0 { if n <= 0 {
return nil, nil, fmt.Errorf("seed size must be greater than zero") return nil, nil, fmt.Errorf("seed size must be greater than zero")
} }
gofakeit.Seed(time.Now().UnixNano()) gofakeit.Seed(time.Now().UnixNano())
items := make([]models.User, 0, n) items := make([]users.User, 0, n)
creds := make([]Credential, 0, n) creds := make([]Credential, 0, n)
for i := 0; i < n; i++ { for i := 0; i < n; i++ {
now := time.Now().UTC() now := time.Now().UTC()
@ -38,20 +39,20 @@ func SeedUsers(db *gorm.DB, n int) ([]models.User, []Credential, error) {
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("generate password: %w", err) return nil, nil, fmt.Errorf("generate password: %w", err)
} }
passwordHash, err := auth.HashPassword(pw) passwordHash, err := systemUtils.HashPassword(pw)
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("hash seed password: %w", err) return nil, nil, fmt.Errorf("hash seed password: %w", err)
} }
item := models.User{ item := users.User{
Email: email, Email: email,
Name: gofakeit.Name(), Name: gofakeit.Name(),
Password: passwordHash, Password: passwordHash,
Roles: models.UserRoles{"user"}, Roles: users.UserRoles{"user"},
Status: models.UserStatusActive, Status: users.UserStatusActive,
Types: models.UserTypes{"internal"}, Types: users.UserTypes{"internal"},
UUID: uuid, UUID: uuid,
Details: &models.UserDetails{ Details: &users.UserDetails{
Title: gofakeit.JobTitle(), Title: gofakeit.JobTitle(),
FirstName: gofakeit.FirstName(), FirstName: gofakeit.FirstName(),
LastName: gofakeit.LastName(), LastName: gofakeit.LastName(),
@ -61,7 +62,7 @@ func SeedUsers(db *gorm.DB, n int) ([]models.User, []Credential, error) {
ZipCode: gofakeit.Zip(), ZipCode: gofakeit.Zip(),
Country: gofakeit.Country(), Country: gofakeit.Country(),
}, },
Preferences: &models.UserPreferences{ Preferences: &users.UserPreferences{
UseIdle: gofakeit.Bool(), UseIdle: gofakeit.Bool(),
IdleTimeout: gofakeit.Number(1, 30), IdleTimeout: gofakeit.Number(1, 30),
UseIdlePassword: gofakeit.Bool(), UseIdlePassword: gofakeit.Bool(),

View File

@ -1,4 +1,4 @@
package auth package systemUtils
import ( import (
"errors" "errors"

View File

@ -0,0 +1,21 @@
package systemUtils
import (
"github.com/gofiber/fiber/v3"
"github.com/golang-jwt/jwt/v5"
)
type Claims struct {
Username string `json:"username"`
TokenType string `json:"type"`
jwt.RegisteredClaims
}
func ClaimsFromCtx(c fiber.Ctx) (*Claims, bool) {
val := c.Locals("authClaims")
if val == nil {
return nil, false
}
claims, ok := val.(*Claims)
return claims, ok
}

View File

@ -1,10 +1,78 @@
package tokens package tokens
import ( import (
"crypto/rand"
"crypto/sha256" "crypto/sha256"
"encoding/base64"
"encoding/hex" "encoding/hex"
"errors"
"server/internal/config"
"time"
"github.com/gofiber/fiber/v3"
"github.com/golang-jwt/jwt/v5"
) )
type TockenService struct {
cfg config.AuthConfig
secret []byte
accessExpiry time.Duration
refreshExpiry time.Duration
}
type Claims struct {
Username string `json:"username"`
Role string `json:"role"`
TokenType string `json:"type"`
jwt.RegisteredClaims
}
// Typescript: interface
type TokenPair struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
}
const (
TokenTypeAccess = "access"
TokenTypeRefresh = "refresh"
)
var Tockens *TockenService
func GetTockenService() (*TockenService, error) {
if Tockens == nil {
cfg, err := config.GetConfig()
if err != nil {
return nil, err
}
Tockens, err = NewTockenService(cfg.Auth)
if err != nil {
return nil, err
}
}
return Tockens, nil
}
func NewTockenService(cfg config.AuthConfig) (*TockenService, error) {
if cfg.Secret == "" {
return nil, errors.New("jwt secret is required")
}
if cfg.AccessTokenExpiryMinutes <= 0 {
return nil, errors.New("access token expiry must be positive")
}
if cfg.RefreshTokenExpiryMinutes <= 0 {
return nil, errors.New("refresh token expiry must be positive")
}
return &TockenService{
cfg: cfg,
secret: []byte(cfg.Secret),
accessExpiry: time.Duration(cfg.AccessTokenExpiryMinutes) * time.Minute,
refreshExpiry: time.Duration(cfg.RefreshTokenExpiryMinutes) * time.Minute,
}, nil
}
func hashToken(token string) string { func hashToken(token string) string {
sum := sha256.Sum256([]byte(token)) sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:]) return hex.EncodeToString(sum[:])
@ -13,3 +81,131 @@ func hashToken(token string) string {
func HashToken(token string) string { func HashToken(token string) string {
return hashToken(token) return hashToken(token)
} }
func (s *TockenService) GenerateTokenPair(username string) (TokenPair, error) {
access, err := s.GenerateToken(username, TokenTypeAccess, s.accessExpiry)
if err != nil {
return TokenPair{}, err
}
refresh, err := s.GenerateToken(username, TokenTypeRefresh, s.refreshExpiry)
if err != nil {
return TokenPair{}, err
}
return TokenPair{
AccessToken: access,
RefreshToken: refresh,
}, nil
}
func (s *TockenService) AccessExpiry() time.Duration {
return s.accessExpiry
}
func (s *TockenService) RefreshExpiry() time.Duration {
return s.refreshExpiry
}
func (s *TockenService) Middleware() fiber.Handler {
return func(c fiber.Ctx) error {
tokenString := c.Get("Auth-Token")
if tokenString == "" {
return fiber.NewError(fiber.StatusUnauthorized, "missing token header")
}
claims, err := s.ParseToken(tokenString)
if err != nil {
return fiber.NewError(fiber.StatusUnauthorized, err.Error())
}
if claims.TokenType != TokenTypeAccess {
return fiber.NewError(fiber.StatusUnauthorized, "access token required")
}
c.Locals("authClaims", claims)
return c.Next()
}
}
func (s *TockenService) Refresh(refreshToken string) (TokenPair, error) {
claims, err := s.ParseToken(refreshToken)
if err != nil {
return TokenPair{}, err
}
if claims.TokenType != TokenTypeRefresh {
return TokenPair{}, errors.New("refresh token required")
}
return s.GenerateTokenPair(claims.Username)
}
func (s *TockenService) ValidateToken(tokenString string) (*Claims, error) {
claims, err := s.ParseToken(tokenString)
if err != nil {
return nil, err
}
if claims.TokenType != TokenTypeAccess {
return nil, errors.New("access token required")
}
return claims, nil
}
func ClaimsFromCtx(c fiber.Ctx) (*Claims, bool) {
val := c.Locals("authClaims")
if val == nil {
return nil, false
}
claims, ok := val.(*Claims)
return claims, ok
}
func (s *TockenService) ParseToken(tokenString string) (*Claims, error) {
claims := &Claims{}
token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fiber.ErrUnauthorized
}
return s.secret, nil
})
if err != nil || !token.Valid {
return nil, errors.New("invalid or expired token")
}
if s.cfg.Issuer != "" && claims.Issuer != "" && claims.Issuer != s.cfg.Issuer {
return nil, errors.New("invalid token issuer")
}
return claims, nil
}
func (s *TockenService) GenerateToken(username, tokenType string, expiry time.Duration) (string, error) {
claims := Claims{
Username: username,
TokenType: tokenType,
RegisteredClaims: jwt.RegisteredClaims{
Issuer: s.cfg.Issuer,
ExpiresAt: jwt.NewNumericDate(time.Now().Add(expiry)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(s.secret)
}
func (s *TockenService) ValidateAccessToken(tokenString string) (*Claims, error) {
claims, err := s.ParseToken(tokenString)
if err != nil {
return nil, err
}
if claims.TokenType != TokenTypeAccess {
return nil, errors.New("access token required")
}
return claims, nil
}
func (s *TockenService) GenerateSecureToken() (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}

View File

@ -6,8 +6,6 @@ import (
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
"server/internal/config"
tsrpc "server/pkg/ts-rpc" tsrpc "server/pkg/ts-rpc"
) )
@ -32,14 +30,6 @@ func TsGenerate() (string, error) {
return "", fmt.Errorf("write local generated typescript: %w", err) return "", fmt.Errorf("write local generated typescript: %w", err)
} }
configPath := os.Getenv("CONFIG_PATH")
if configPath == "" {
configPath = "configs/config.json"
}
if _, err := config.LoadConfig(configPath); err != nil {
return "", fmt.Errorf("load config from %s: %w", configPath, err)
}
frontendAPIPath := os.Getenv("FRONTEND_API_PATH") frontendAPIPath := os.Getenv("FRONTEND_API_PATH")
if frontendAPIPath == "" { if frontendAPIPath == "" {
return "", errors.New("FRONTEND_API_PATH must be set") return "", errors.New("FRONTEND_API_PATH must be set")

View File

@ -1,25 +1,34 @@
package user package users
import ( import (
"encoding/json"
"errors" "errors"
"fmt"
"strconv" "strconv"
"strings"
"time" "time"
"github.com/gofiber/fiber/v3" "github.com/gofiber/fiber/v3"
"github.com/google/uuid" "github.com/google/uuid"
"gorm.io/gorm" "gorm.io/gorm"
"server/internal/auth" "server/internal/db"
"server/internal/helpers" "server/internal/mail"
"server/internal/models"
"server/internal/responses" "server/internal/responses"
"server/internal/systemUtils"
"server/internal/tokens"
"server/internal/validation" "server/internal/validation"
) )
type UserController struct{} type UserController struct {
TockenService *tokens.TockenService
}
func NewUserController() *UserController { func NewUserController(tockenService *tokens.TockenService) *UserController {
return &UserController{} return &UserController{
TockenService: tockenService,
}
} }
// Typescript: interface // Typescript: interface
@ -27,12 +36,12 @@ type UpdateUserRequest struct {
Name string `json:"name" validate:"required,min=1,max=255"` Name string `json:"name" validate:"required,min=1,max=255"`
Email string `json:"email" validate:"required,email"` Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"omitempty,min=8,max=128"` Password string `json:"password" validate:"omitempty,min=8,max=128"`
Roles models.UserRoles `json:"roles"` Roles UserRoles `json:"roles"`
Status models.UserStatus `json:"status"` Status UserStatus `json:"status"`
Types models.UserTypes `json:"types"` Types UserTypes `json:"types"`
Avatar *string `json:"avatar"` Avatar *string `json:"avatar"`
Details *models.UserDetailsShort `json:"details"` Details *UserDetails `json:"details"`
Preferences *models.UserPreferencesShort `json:"preferences"` Preferences *UserPreferences `json:"preferences"`
} }
// GetUser returns a single user by UUID. // GetUser returns a single user by UUID.
@ -41,12 +50,12 @@ func (uc *UserController) GetUser(c fiber.Ctx) error {
if err != nil { if err != nil {
return err return err
} }
return c.JSON(responses.Success(models.ToUserProfile(user))) return c.JSON(responses.Success(ToUserProfile(user)))
} }
// CreateUser creates a user together with optional details and preferences. // CreateUser creates a user together with optional details and preferences.
func (uc *UserController) CreateUser(c fiber.Ctx) error { func (uc *UserController) CreateUser(c fiber.Ctx) error {
var req models.UserCreateInput var req UserCreateInput
if err := c.Bind().Body(&req); err != nil { if err := c.Bind().Body(&req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "invalid payload") return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
} }
@ -54,50 +63,50 @@ func (uc *UserController) CreateUser(c fiber.Ctx) error {
return err return err
} }
db, err := helpers.DBFromCtx(c) db, err := db.DBFromCtx(c)
if err != nil { if err != nil {
return err return err
} }
var existing models.User var existing User
if err := db.Where("email = ?", req.Email).First(&existing).Error; err == nil { if err := db.Where("email = ?", req.Email).First(&existing).Error; err == nil {
return fiber.NewError(fiber.StatusConflict, "user already exists") return fiber.NewError(fiber.StatusConflict, "user already exists")
} else if !errors.Is(err, gorm.ErrRecordNotFound) { } else if !errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusInternalServerError, "failed to check user") return fiber.NewError(fiber.StatusInternalServerError, "failed to check user")
} }
hashedPassword, err := auth.HashPassword(req.Password) hashedPassword, err := systemUtils.HashPassword(req.Password)
if err != nil { if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to secure password") return fiber.NewError(fiber.StatusInternalServerError, "failed to secure password")
} }
now := time.Now().UTC() now := time.Now().UTC()
user := models.User{ user := User{
Email: req.Email, Email: req.Email,
Name: req.Name, Name: req.Name,
Password: hashedPassword, Password: hashedPassword,
Roles: func() models.UserRoles { Roles: func() UserRoles {
if len(req.Roles) == 0 { if len(req.Roles) == 0 {
return models.UserRoles{"user"} return UserRoles{"user"}
} }
return req.Roles return req.Roles
}(), }(),
Status: func() models.UserStatus { Status: func() UserStatus {
if req.Status == "" { if req.Status == "" {
return models.UserStatusPending return UserStatusPending
} }
return req.Status return req.Status
}(), }(),
Types: func() models.UserTypes { Types: func() UserTypes {
if len(req.Types) == 0 { if len(req.Types) == 0 {
return models.UserTypes{"internal"} return UserTypes{"internal"}
} }
return req.Types return req.Types
}(), }(),
Avatar: req.Avatar, Avatar: req.Avatar,
UUID: uuid.NewString(), UUID: uuid.NewString(),
Details: helpers.ToUserDetails(req.Details), Details: req.Details,
Preferences: helpers.ToUserPreferences(req.Preferences), Preferences: req.Preferences,
CreatedAt: now, CreatedAt: now,
UpdatedAt: now, UpdatedAt: now,
} }
@ -110,7 +119,7 @@ func (uc *UserController) CreateUser(c fiber.Ctx) error {
return fiber.NewError(fiber.StatusInternalServerError, "failed to reload user") return fiber.NewError(fiber.StatusInternalServerError, "failed to reload user")
} }
return c.Status(fiber.StatusCreated).JSON(responses.Success(models.ToUserProfile(&user))) return c.Status(fiber.StatusCreated).JSON(responses.Success(ToUserProfile(&user)))
} }
// UpdateUser replaces user fields and synchronizes details/preferences. // UpdateUser replaces user fields and synchronizes details/preferences.
@ -123,7 +132,7 @@ func (uc *UserController) UpdateUser(c fiber.Ctx) error {
return err return err
} }
db, err := helpers.DBFromCtx(c) db, err := db.DBFromCtx(c)
if err != nil { if err != nil {
return err return err
} }
@ -134,7 +143,7 @@ func (uc *UserController) UpdateUser(c fiber.Ctx) error {
} }
if req.Email != user.Email { if req.Email != user.Email {
var existing models.User var existing User
if err := db.Select("id").Where("email = ?", req.Email).First(&existing).Error; err == nil && existing.ID != user.ID { if err := db.Select("id").Where("email = ?", req.Email).First(&existing).Error; err == nil && existing.ID != user.ID {
return fiber.NewError(fiber.StatusConflict, "user already exists") return fiber.NewError(fiber.StatusConflict, "user already exists")
} else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { } else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
@ -176,12 +185,12 @@ func (uc *UserController) UpdateUser(c fiber.Ctx) error {
return fiber.NewError(fiber.StatusInternalServerError, "failed to reload user") return fiber.NewError(fiber.StatusInternalServerError, "failed to reload user")
} }
return c.JSON(responses.Success(models.ToUserProfile(user))) return c.JSON(responses.Success(ToUserProfile(user)))
} }
// DeleteUser removes a user and linked details/preferences through cascading delete rules. // DeleteUser removes a user and linked details/preferences through cascading delete rules.
func (uc *UserController) DeleteUser(c fiber.Ctx) error { func (uc *UserController) DeleteUser(c fiber.Ctx) error {
db, err := helpers.DBFromCtx(c) db, err := db.DBFromCtx(c)
if err != nil { if err != nil {
return err return err
} }
@ -192,10 +201,10 @@ func (uc *UserController) DeleteUser(c fiber.Ctx) error {
} }
if err := db.Transaction(func(tx *gorm.DB) error { if err := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("user_id = ?", user.ID).Delete(&models.UserDetails{}).Error; err != nil { if err := tx.Where("user_id = ?", user.ID).Delete(&UserDetails{}).Error; err != nil {
return err return err
} }
if err := tx.Where("user_id = ?", user.ID).Delete(&models.UserPreferences{}).Error; err != nil { if err := tx.Where("user_id = ?", user.ID).Delete(&UserPreferences{}).Error; err != nil {
return err return err
} }
return tx.Delete(user).Error return tx.Delete(user).Error
@ -206,18 +215,348 @@ func (uc *UserController) DeleteUser(c fiber.Ctx) error {
return c.JSON(responses.Success(responses.SimpleResponse{Message: "user deleted"})) return c.JSON(responses.Success(responses.SimpleResponse{Message: "user deleted"}))
} }
func loadUserByID(c fiber.Ctx) (*models.User, error) { // Login authenticates a user and issues an access/refresh token pair.
func (uc *UserController) Login(c fiber.Ctx) error {
var req LoginRequest
if err := c.Bind().Body(&req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
}
if err := validation.ValidateStruct(&req); err != nil {
return err
}
db, err := db.DBFromCtx(c)
if err != nil {
return err
}
var user User
if err := db.Where("email = ?", req.Username).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusUnauthorized, "invalid credentials")
}
return fiber.NewError(fiber.StatusInternalServerError, "failed to fetch user")
}
match, err := systemUtils.VerifyPassword(user.Password, req.Password)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to verify credentials")
}
if !match {
return fiber.NewError(fiber.StatusUnauthorized, "invalid credentials")
}
token, err := uc.TockenService.GenerateTokenPair(user.Email)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to issue token")
}
userID := user.ID
now := time.Now().UTC()
if err := db.Where("expires_at < ?", now).Delete(&Session{}).Error; err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to purge expired sessions")
}
session := Session{
UserID: &userID,
Username: user.Email,
AccessTokenHash: tokens.HashToken(token.AccessToken),
RefreshTokenHash: tokens.HashToken(token.RefreshToken),
ExpiresAt: now.Add(uc.TockenService.RefreshExpiry()),
IPAddress: c.IP(),
UserAgent: c.Get("User-Agent"),
}
if err := db.Create(&session).Error; err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to record session")
}
c.Response().Header.Set("Auth-Token", token.AccessToken)
return c.JSON(responses.Success(token))
}
// Register creates a new user with optional roles/types/preferences.
func (uc *UserController) Register(c fiber.Ctx) error {
var req UserCreateInput
if err := c.Bind().Body(&req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
}
if err := validation.ValidateStruct(&req); err != nil {
return err
}
db, err := db.DBFromCtx(c)
if err != nil {
return err
}
var existing User
if err := db.Where("email = ?", req.Email).First(&existing).Error; err == nil {
return fiber.NewError(fiber.StatusConflict, "user already exists")
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusInternalServerError, "failed to check user")
}
now := time.Now().UTC()
hashedPassword, err := systemUtils.HashPassword(req.Password)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to secure password")
}
user := User{
Email: req.Email,
Name: req.Name,
Password: hashedPassword,
Roles: func() UserRoles {
if len(req.Roles) == 0 {
return UserRoles{"user"}
}
return req.Roles
}(),
Status: func() UserStatus {
if req.Status == "" {
return UserStatusPending
}
return req.Status
}(),
Types: func() UserTypes {
if len(req.Types) == 0 {
return UserTypes{"internal"}
}
return req.Types
}(),
Avatar: req.Avatar,
UUID: uuid.NewString(),
Details: req.Details,
Preferences: func() *UserPreferences {
if req.Preferences == nil {
return nil
}
return &UserPreferences{
UseIdle: req.Preferences.UseIdle,
IdleTimeout: req.Preferences.IdleTimeout,
UseIdlePassword: req.Preferences.UseIdlePassword,
IdlePin: req.Preferences.IdlePin,
UseDirectLogin: req.Preferences.UseDirectLogin,
UseQuadcodeLogin: req.Preferences.UseQuadcodeLogin,
SendNoticesMail: req.Preferences.SendNoticesMail,
Language: req.Preferences.Language,
}
}(),
CreatedAt: now,
UpdatedAt: now,
}
if err := db.Create(&user).Error; err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to create user")
}
mailService, err := mail.New()
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to initialize mail service")
}
if err := mailService.Send(c, mail.Message{
To: user.Email,
Subject: fmt.Sprintf("[%s] Registrazione completata", mailService.AppName()),
Template: "registration",
TemplateData: mail.TemplateData{
AppName: mailService.AppName(),
UserName: user.Name,
UserEmail: user.Email,
},
}); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to send registration email")
}
return c.Status(fiber.StatusCreated).JSON(responses.Success(&user))
}
func (uc *UserController) ForgotPassword(c fiber.Ctx) error {
var req ForgotPasswordRequest
if err := c.Bind().Body(&req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
}
if err := validation.ValidateStruct(&req); err != nil {
return err
}
db, err := db.DBFromCtx(c)
if err != nil {
return err
}
var user User
if err := db.Where("email = ?", req.Email).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return c.JSON(responses.Success(fiber.Map{"sent": true}))
}
return fiber.NewError(fiber.StatusInternalServerError, "failed to load user")
}
if user.Status == UserStatusDisabled {
return c.JSON(responses.Success(fiber.Map{"sent": true}))
}
resetToken, err := uc.TockenService.GenerateSecureToken()
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to generate reset token")
}
now := time.Now().UTC()
record := PasswordResetToken{
UserID: user.ID,
TokenHash: tokens.HashToken(resetToken),
ExpiresAt: now.Add(30 * time.Minute),
CreatedAt: now,
UpdatedAt: now,
}
if err := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("user_id = ? OR expires_at < ? OR used_at IS NOT NULL", user.ID, now).
Delete(&PasswordResetToken{}).Error; err != nil {
return err
}
return tx.Create(&record).Error
}); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to store reset token")
}
mailService, err := mail.New()
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to initialize mail service")
}
if err := mailService.Send(c, mail.Message{
To: user.Email,
Subject: fmt.Sprintf("[%s] Recupero password", mailService.AppName()),
Template: "password_reset",
TemplateData: mail.TemplateData{
AppName: mailService.AppName(),
UserName: user.Name,
UserEmail: user.Email,
ResetToken: resetToken,
ResetURL: mailService.ResetLink(resetToken),
},
}); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to send reset email")
}
return c.JSON(responses.Success(responses.SimpleResponse{Message: "password reset email sent"}))
}
func (uc *UserController) ResetPassword(c fiber.Ctx) error {
var req ResetPasswordRequest
if err := c.Bind().Body(&req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
}
if err := validation.ValidateStruct(&req); err != nil {
return err
}
db, err := db.DBFromCtx(c)
if err != nil {
return err
}
hashedPassword, err := systemUtils.HashPassword(req.Password)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to secure password")
}
now := time.Now().UTC()
tokenHash := tokens.HashToken(req.Token)
if err := db.Transaction(func(tx *gorm.DB) error {
var resetToken PasswordResetToken
if err := tx.Where("token_hash = ?", tokenHash).First(&resetToken).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusBadRequest, "invalid or expired reset token")
}
return err
}
if resetToken.UsedAt != nil || now.After(resetToken.ExpiresAt) {
return fiber.NewError(fiber.StatusBadRequest, "invalid or expired reset token")
}
if err := tx.Model(&User{}).Where("id = ?", resetToken.UserID).Updates(map[string]any{
"password": hashedPassword,
"updated_at": now,
}).Error; err != nil {
return err
}
if err := tx.Model(&resetToken).Updates(map[string]any{
"used_at": now,
"updated_at": now,
}).Error; err != nil {
return err
}
if err := tx.Where("user_id = ?", resetToken.UserID).Delete(&Session{}).Error; err != nil {
return err
}
if err := tx.Where("user_id = ? AND id <> ?", resetToken.UserID, resetToken.ID).Delete(&PasswordResetToken{}).Error; err != nil {
return err
}
return nil
}); err != nil {
var fiberErr *fiber.Error
if errors.As(err, &fiberErr) {
return fiberErr
}
return fiber.NewError(fiber.StatusInternalServerError, "failed to reset password")
}
return c.JSON(responses.Success(responses.SimpleResponse{Message: "password reset successful"}))
}
func (uc *UserController) ValidToken(c fiber.Ctx) error {
raw := strings.TrimSpace(string(c.Body()))
if raw == "" {
return fiber.NewError(fiber.StatusBadRequest, "token is required")
}
token := raw
if strings.HasPrefix(raw, "\"") && strings.HasSuffix(raw, "\"") {
if err := json.Unmarshal([]byte(raw), &token); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
}
}
token = strings.TrimSpace(token)
if token == "" {
return fiber.NewError(fiber.StatusBadRequest, "token is required")
}
db, err := db.DBFromCtx(c)
if err != nil {
return err
}
now := time.Now().UTC()
tokenHash := tokens.HashToken(token)
var resetToken PasswordResetToken
if err := db.Where("token_hash = ?", tokenHash).First(&resetToken).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusBadRequest, "invalid or expired reset token")
}
return fiber.NewError(fiber.StatusInternalServerError, "failed to validate reset token")
}
if resetToken.UsedAt != nil || now.After(resetToken.ExpiresAt) {
return fiber.NewError(fiber.StatusBadRequest, "invalid or expired reset token")
}
return c.JSON(responses.Success(responses.SimpleResponse{Message: "valid reset token"}))
}
func loadUserByID(c fiber.Ctx) (*User, error) {
id, err := strconv.Atoi(c.Params("id")) id, err := strconv.Atoi(c.Params("id"))
if err != nil || id <= 0 { if err != nil || id <= 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "invalid user id") return nil, fiber.NewError(fiber.StatusBadRequest, "invalid user id")
} }
db, err := helpers.DBFromCtx(c) db, err := db.DBFromCtx(c)
if err != nil { if err != nil {
return nil, err return nil, err
} }
var user models.User var user User
if err := db.Preload("Details").Preload("Preferences").First(&user, id).Error; err != nil { if err := db.Preload("Details").Preload("Preferences").First(&user, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "user not found") return nil, fiber.NewError(fiber.StatusNotFound, "user not found")
@ -228,18 +567,18 @@ func loadUserByID(c fiber.Ctx) (*models.User, error) {
return &user, nil return &user, nil
} }
func loadUserByUUID(c fiber.Ctx) (*models.User, error) { func loadUserByUUID(c fiber.Ctx) (*User, error) {
uuid := c.Params("uuid") uuid := c.Params("uuid")
if uuid == "" { if uuid == "" {
return nil, fiber.NewError(fiber.StatusBadRequest, "invalid user uuid") return nil, fiber.NewError(fiber.StatusBadRequest, "invalid user uuid")
} }
db, err := helpers.DBFromCtx(c) db, err := db.DBFromCtx(c)
if err != nil { if err != nil {
return nil, err return nil, err
} }
var user models.User var user User
if err := db.Preload("Details").Preload("Preferences").First(&user, "uuid = ?", uuid).Error; err != nil { if err := db.Preload("Details").Preload("Preferences").First(&user, "uuid = ?", uuid).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "user not found") return nil, fiber.NewError(fiber.StatusNotFound, "user not found")
@ -250,15 +589,15 @@ func loadUserByUUID(c fiber.Ctx) (*models.User, error) {
return &user, nil return &user, nil
} }
func syncUserDetails(tx *gorm.DB, userID int, input *models.UserDetailsShort) error { func syncUserDetails(tx *gorm.DB, userID int, input *UserDetails) error {
if input == nil { if input == nil {
return tx.Where("user_id = ?", userID).Delete(&models.UserDetails{}).Error return tx.Where("user_id = ?", userID).Delete(&UserDetails{}).Error
} }
var details models.UserDetails var details UserDetails
if err := tx.Where("user_id = ?", userID).First(&details).Error; err != nil { if err := tx.Where("user_id = ?", userID).First(&details).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
details = models.UserDetails{UserID: userID} details = UserDetails{UserID: userID}
} else { } else {
return err return err
} }
@ -279,15 +618,15 @@ func syncUserDetails(tx *gorm.DB, userID int, input *models.UserDetailsShort) er
return tx.Save(&details).Error return tx.Save(&details).Error
} }
func syncUserPreferences(tx *gorm.DB, userID int, input *models.UserPreferencesShort) error { func syncUserPreferences(tx *gorm.DB, userID int, input *UserPreferences) error {
if input == nil { if input == nil {
return tx.Where("user_id = ?", userID).Delete(&models.UserPreferences{}).Error return tx.Where("user_id = ?", userID).Delete(&UserPreferences{}).Error
} }
var preferences models.UserPreferences var preferences UserPreferences
if err := tx.Where("user_id = ?", userID).First(&preferences).Error; err != nil { if err := tx.Where("user_id = ?", userID).First(&preferences).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
preferences = models.UserPreferences{UserID: userID} preferences = UserPreferences{UserID: userID}
} else { } else {
return err return err
} }
@ -310,17 +649,17 @@ func syncUserPreferences(tx *gorm.DB, userID int, input *models.UserPreferencesS
// Me returns the authenticated user's profile (short format). // Me returns the authenticated user's profile (short format).
func (uc *UserController) Me(c fiber.Ctx) error { func (uc *UserController) Me(c fiber.Ctx) error {
claims, ok := auth.ClaimsFromCtx(c) claims, ok := systemUtils.ClaimsFromCtx(c)
if !ok { if !ok {
return fiber.NewError(fiber.StatusUnauthorized, "missing claims") return fiber.NewError(fiber.StatusUnauthorized, "missing claims")
} }
db, err := helpers.DBFromCtx(c) db, err := db.DBFromCtx(c)
if err != nil { if err != nil {
return err return err
} }
var user models.User var user User
if err := db.Preload("Details").Preload("Preferences").Where("email = ?", claims.Username).First(&user).Error; err != nil { if err := db.Preload("Details").Preload("Preferences").Where("email = ?", claims.Username).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusNotFound, "user not found") return fiber.NewError(fiber.StatusNotFound, "user not found")
@ -328,5 +667,28 @@ func (uc *UserController) Me(c fiber.Ctx) error {
return fiber.NewError(fiber.StatusInternalServerError, "failed to load user") return fiber.NewError(fiber.StatusInternalServerError, "failed to load user")
} }
return c.JSON(responses.Success(models.ToUserShort(&user))) return c.JSON(responses.Success(&user))
}
func (us *UserController) Refresh(c fiber.Ctx) error {
var req RefreshRequest
if err := c.Bind().Body(&req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
}
if req.RefreshToken == "" {
return fiber.NewError(fiber.StatusBadRequest, "refresh_token is required")
}
claims, err := us.TockenService.ParseToken(req.RefreshToken)
if err != nil {
return fiber.NewError(fiber.StatusUnauthorized, err.Error())
}
if claims.TokenType != tokens.TokenTypeRefresh {
return fiber.NewError(fiber.StatusUnauthorized, "refresh token required")
}
tokens, err := us.TockenService.GenerateTokenPair(claims.Username)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
return c.JSON(responses.Success(tokens))
} }

View File

@ -1,4 +1,4 @@
package models package users
import ( import (
"time" "time"
@ -11,9 +11,6 @@ import (
// Typescript: type // Typescript: type
type UserRoles []string type UserRoles []string
// Typescript: type
type UsersShort []UserShort
type User struct { type User struct {
ID int `json:"id" gorm:"primaryKey"` ID int `json:"id" gorm:"primaryKey"`
Email string `json:"email" gorm:"uniqueIndex;size:255"` Email string `json:"email" gorm:"uniqueIndex;size:255"`
@ -43,44 +40,13 @@ type UserCreateInput struct {
Status UserStatus `json:"status"` Status UserStatus `json:"status"`
Types UserTypes `json:"types"` Types UserTypes `json:"types"`
Avatar *string `json:"avatar"` Avatar *string `json:"avatar"`
Details *UserDetailsShort `json:"details" ` Details *UserDetails `json:"details" `
Preferences *UserPreferencesShort `json:"preferences" ` Preferences *UserPreferences `json:"preferences" `
} }
// UserTypes is stored as JSON array (e.g. ["internal","external"]). // UserTypes is stored as JSON array (e.g. ["internal","external"]).
type UserTypes []string type UserTypes []string
// UserShort is a lightweight representation of User without sensitive data.
// Typescript: interface
type UserShort struct {
Email string `json:"email" `
Name string `json:"name" `
Roles UserRoles `json:"roles" `
Status UserStatus `json:"status" `
UUID string `json:"uuid" `
Details *UserDetailsShort `json:"details" `
Preferences *UserPreferencesShort `json:"preferences" `
Avatar *string `json:"avatar" `
}
// ToUserShort maps a User to the lightweight view.
func ToUserShort(u *User) UserShort {
if u == nil {
return UserShort{}
}
return UserShort{
Email: u.Email,
Name: u.Name,
Roles: u.Roles,
Status: u.Status,
UUID: u.UUID,
Details: ToUserDetailsShort(u.Details),
Preferences: ToUserPreferencesShort(u.Preferences),
Avatar: u.Avatar,
}
}
// UserProfile is the safe full representation of a user returned by CRUD endpoints. // UserProfile is the safe full representation of a user returned by CRUD endpoints.
// //
// Typescript: interface // Typescript: interface
@ -122,40 +88,6 @@ func ToUserProfile(u *User) UserProfile {
} }
} }
// ToUserDetailsShort maps UserDetails to the short version.
func ToUserDetailsShort(d *UserDetails) *UserDetailsShort {
if d == nil {
return nil
}
return &UserDetailsShort{
Title: d.Title,
FirstName: d.FirstName,
LastName: d.LastName,
Address: d.Address,
City: d.City,
ZipCode: d.ZipCode,
Country: d.Country,
Phone: d.Phone,
}
}
// ToUserPreferencesShort maps UserPreferences to the short version.
func ToUserPreferencesShort(p *UserPreferences) *UserPreferencesShort {
if p == nil {
return nil
}
return &UserPreferencesShort{
UseIdle: p.UseIdle,
IdleTimeout: p.IdleTimeout,
UseIdlePassword: p.UseIdlePassword,
IdlePin: p.IdlePin,
UseDirectLogin: p.UseDirectLogin,
UseQuadcodeLogin: p.UseQuadcodeLogin,
SendNoticesMail: p.SendNoticesMail,
Language: p.Language,
}
}
// UserPreferences holds per-user settings stored as JSON. // UserPreferences holds per-user settings stored as JSON.
type UserPreferences struct { type UserPreferences struct {
ID int `json:"id" gorm:"primaryKey"` ID int `json:"id" gorm:"primaryKey"`
@ -174,18 +106,6 @@ type UserPreferences struct {
// UserPreferences holds per-user settings stored as JSON. // UserPreferences holds per-user settings stored as JSON.
// Typescript: interface
type UserPreferencesShort struct {
UseIdle bool `json:"useIdle"`
IdleTimeout int `json:"idleTimeout"`
UseIdlePassword bool `json:"useIdlePassword"`
IdlePin string `json:"idlePin"`
UseDirectLogin bool `json:"useDirectLogin"`
UseQuadcodeLogin bool `json:"useQuadcodeLogin"`
SendNoticesMail bool `json:"sendNoticesMail"`
Language string `json:"language"`
}
// UserDetails holds optional profile data. // UserDetails holds optional profile data.
type UserDetails struct { type UserDetails struct {
ID int `json:"id" gorm:"primaryKey"` ID int `json:"id" gorm:"primaryKey"`
@ -204,18 +124,6 @@ type UserDetails struct {
// UserDetails holds optional profile data. // UserDetails holds optional profile data.
// Typescript: interface
type UserDetailsShort struct {
Title string `json:"title"`
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
Address string `json:"address"`
City string `json:"city"`
ZipCode string `json:"zipCode"`
Country string `json:"country"`
Phone string `json:"phone"`
}
// Session tracks logins with browser metadata. // Session tracks logins with browser metadata.
type Session struct { type Session struct {
@ -252,3 +160,25 @@ const (
UserStatusActive UserStatus = "active" UserStatusActive UserStatus = "active"
UserStatusDisabled UserStatus = "disabled" UserStatusDisabled UserStatus = "disabled"
) )
// Typescript: interface
type LoginRequest struct {
Username string `json:"username" validate:"required,email"`
Password string `json:"password" validate:"required,min=8,max=128"`
}
// Typescript: interface
type RefreshRequest struct {
RefreshToken string `json:"refresh_token"`
}
// Typescript: interface
type ForgotPasswordRequest struct {
Email string `json:"email" validate:"required,email"`
}
// Typescript: interface
type ResetPasswordRequest struct {
Token string `json:"token" validate:"required,min=20,max=255"`
Password string `json:"password" validate:"required,min=8,max=128"`
}

View File

@ -1,28 +1,60 @@
package user package users
import ( import (
"server/internal/auth" "fmt"
"server/internal/authorization" "server/internal/roles"
"server/internal/tokens"
"time"
"github.com/gofiber/fiber/v3" "github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/middleware/limiter"
) )
func RegisterUserRoutes(app *fiber.App, authService *auth.AuthService) { func RegisterUserRoutes(app *fiber.App) {
userController := NewUserController() tockenService, err := tokens.GetTockenService()
if err != nil {
// Typescript: TSEndpoint= path=/users/:uuid; name=getUser; method=GET; response=models.UserProfile panic(fmt.Sprintf("token service: %v", err))
app.Get("/users/:uuid", authService.Middleware(), userController.GetUser) }
// Typescript: TSEndpoint= path=/users; name=createUser; method=POST; request=models.UserCreateInput; response=models.UserProfile authRateLimiter := limiter.New(limiter.Config{
app.Post("/users", authService.Middleware(), userController.CreateUser) Max: 10,
Expiration: time.Minute,
// Typescript: TSEndpoint= path=/users/:uuid; name=updateUser; method=PUT; request=controllers.UpdateUserRequest; response=models.UserProfile LimiterMiddleware: limiter.SlidingWindow{},
app.Put("/users/:uuid", authService.Middleware(), userController.UpdateUser) })
// Typescript: TSEndpoint= path=/users/:uuid; name=deleteUser; method=DELETE; response=controllers.SimpleResponse userController := NewUserController(tockenService)
app.Delete("/users/:uuid", authService.Middleware(), userController.DeleteUser)
// Typescript: TSEndpoint= path=/users/:uuid; name=getUser; method=GET; response=users.UserProfile
// Typescript: TSEndpoint= path=/auth/me; name=me; method=GET; response=models.UserShort app.Get("/users/:uuid", tockenService.Middleware(), userController.GetUser)
app.Get("/auth/me", authService.Middleware(), userController.Me)
authorization.RegisterEndpoint("GET/auth/me", int(authorization.UserPermission)) // Typescript: TSEndpoint= path=/users; name=createUser; method=POST; request=users.UserCreateInput; response=users.UserProfile
app.Post("/users", tockenService.Middleware(), userController.CreateUser)
// Typescript: TSEndpoint= path=/users/:uuid; name=updateUser; method=PUT; request=users.UpdateUserRequest; response=users.UserProfile
app.Put("/users/:uuid", tockenService.Middleware(), userController.UpdateUser)
// Typescript: TSEndpoint= path=/users/:uuid; name=deleteUser; method=DELETE; response=responses.SimpleResponse
app.Delete("/users/:uuid", tockenService.Middleware(), userController.DeleteUser)
// Typescript: TSEndpoint= path=/auth/me; name=me; method=GET; response=users.User
app.Get("/auth/me", tockenService.Middleware(), userController.Me)
roles.RegisterEndpoint("GET/auth/me", int(roles.UserPermission))
// Typescript: TSEndpoint= path=/auth/login; name=login; method=POST; request=users.LoginRequest; response=tokens.TokenPair
app.Post("/auth/login", authRateLimiter, userController.Login)
// Typescript: TSEndpoint= path=/auth/refresh; name=refresh; method=POST; request=tokens.RefreshRequest; response=tokens.TokenPair
app.Post("/auth/refresh", authRateLimiter, userController.Refresh)
// Typescript: TSEndpoint= path=/auth/register; name=register; method=POST; request=users.UserCreateInput; response=users.User
app.Post("/auth/register", authRateLimiter, userController.Register)
// Typescript: TSEndpoint= path=/auth/password/forgot; name=forgotPassword; method=POST; request=users.ForgotPasswordRequest; response=responses.SimpleResponse
app.Post("/auth/password/forgot", authRateLimiter, userController.ForgotPassword)
// Typescript: TSEndpoint= path=/auth/password/reset; name=resetPassword; method=POST; request=users.ResetPasswordRequest; response=responses.SimpleResponse
app.Post("/auth/password/reset", authRateLimiter, userController.ResetPassword)
// Typescript: TSEndpoint= path=/auth/password/valid; name=validToken; method=POST; request=string; response=responses.SimpleResponse
app.Post("/auth/password/valid", authRateLimiter, userController.ValidToken)
} }