From 3731e6e409928c0a0eda0797c7475242c09a9304 Mon Sep 17 00:00:00 2001 From: fabio Date: Mon, 6 Apr 2026 21:37:06 +0200 Subject: [PATCH] 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. --- backend/GeneratedCode/generatedTypescript.ts | 490 +++++++++--------- backend/cmd/server/main.go | 46 +- backend/configs/roles.json | 22 - backend/internal/admin/controller.go | 32 +- backend/internal/admin/routes.go | 6 +- backend/internal/auth/controller.go | 380 -------------- backend/internal/auth/model.go | 26 - backend/internal/auth/routes.go | 37 -- backend/internal/auth/service.go | 149 ------ backend/internal/config/config.go | 12 +- backend/internal/db/db.go | 27 +- backend/internal/helpers/services.go | 62 --- backend/internal/mail/service.go | 26 +- backend/internal/migrations/init.go | 15 + .../controller.go => roles/permissions.go} | 24 +- backend/internal/routes/register.go | 9 +- backend/internal/seed/seed.go | 23 +- .../{auth => systemUtils}/password.go | 2 +- backend/internal/systemUtils/utils.go | 21 + backend/internal/tokens/services.go | 186 +++++++ backend/internal/tsgenerator/generator.go | 2 +- backend/internal/user/controller.go | 460 ++++++++++++++-- .../{models/user.go => user/model.go} | 134 ++--- backend/internal/user/routes.go | 55 +- 24 files changed, 1057 insertions(+), 1189 deletions(-) delete mode 100644 backend/configs/roles.json delete mode 100644 backend/internal/auth/controller.go delete mode 100644 backend/internal/auth/model.go delete mode 100644 backend/internal/auth/routes.go delete mode 100644 backend/internal/auth/service.go delete mode 100644 backend/internal/helpers/services.go create mode 100644 backend/internal/migrations/init.go rename backend/internal/{authorization/controller.go => roles/permissions.go} (74%) rename backend/internal/{auth => systemUtils}/password.go (97%) create mode 100644 backend/internal/systemUtils/utils.go rename backend/internal/{models/user.go => user/model.go} (65%) diff --git a/backend/GeneratedCode/generatedTypescript.ts b/backend/GeneratedCode/generatedTypescript.ts index 1a0ff9d..20fdeef 100644 --- a/backend/GeneratedCode/generatedTypescript.ts +++ b/backend/GeneratedCode/generatedTypescript.ts @@ -4,7 +4,7 @@ // // 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 { @@ -280,10 +280,205 @@ export type Nullable = T | null; export type Record = { [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 }> => { + return (await api.GET(`/users/${uuid}`)) as { + data: UserProfile; + error: Nullable; + }; +}; + +// 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 }> => { + return (await api.POST("/users", data)) as { + data: UserProfile; + error: Nullable; + }; +}; + +// 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 }> => { + return (await api.PUT("/users/:uuid", data)) as { + data: UserProfile; + error: Nullable; + }; +}; + +// 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 }> => { + return (await api.DELETE(`/users/${uuid}`)) as { + data: SimpleResponse; + error: Nullable; + }; +}; + +// 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; +}> => { + return (await api.GET("/auth/me")) as { + data: UserShort; + error: Nullable; + }; +}; + +export interface UpdateUserRequest { + name: string; + email: string; + password: string; + roles: models.UserRoles; + status: models.UserStatus; + types: models.UserTypes; + avatar: Nullable; + details: Nullable; + preferences: Nullable; +} + +// +// 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; + preferences: Nullable; + avatar: Nullable; +} + +export interface UserCreateInput { + name: string; + email: string; + password: string; + roles: UserRoles; + status: UserStatus; + types: UserTypes; + avatar: Nullable; + details: Nullable; + preferences: Nullable; +} + +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 }> => { + return (await api.PUT("/admin/users/:uuid/block", data)) as { + data: UserShort; + error: Nullable; + }; +}; + +// 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 }> => { + return (await api.POST("/admin/users", data)) as { + data: UserShort[]; + error: Nullable; + }; +}; + +export interface BlockUserRequest { + action: string; +} + +export interface ListUsersRequest { + page: number; + pageSize: number; +} + +// +// package responses +// + +export interface SimpleResponse { + message: string; +} + // // 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; +}> => { + return (await api.GET("/health")) as { + data: string; + error: Nullable; + }; +}; + // Typescript: TSEndpoint= path=/metrics; name=metrics; method=GET; response=string // internal/systemUtils/routes.go Line: 37 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; -}> => { - return (await api.GET("/health")) as { - data: string; - error: Nullable; - }; -}; - export interface MailDebugItem { name: string; content: string; } // -// package admin +// package routes // -// 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 }> => { - return (await api.POST("/admin/users", data)) as { - data: UserShort[]; - error: Nullable; - }; -}; - -// 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 }> => { - return (await api.PUT("/admin/users/:uuid/block", data)) as { - data: UserShort; - error: Nullable; - }; -}; - -export interface BlockUserRequest { - action: string; +export interface FormRequest { + req: string; + count: number; } -export interface ListUsersRequest { - page: number; - pageSize: number; +export interface FormResponse { + test: string; } // // 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 }> => { - return (await api.POST("/auth/refresh", data)) as { - data: TokenPair; - error: Nullable; - }; -}; - -// 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; -}> => { - return (await api.GET("/auth/me")) as { - data: UserShort; - error: Nullable; - }; -}; - -// 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 }> => { - return (await api.POST("/auth/register", data)) as { - data: UserShort; - error: Nullable; - }; -}; - -// 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 }> => { - return (await api.POST("/auth/password/forgot", data)) as { - data: SimpleResponse; - error: Nullable; - }; -}; - // 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 ( 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 -// internal/auth/routes.go Line: 38 +// internal/auth/routes.go Line: 35 export const validToken = async ( data: string, @@ -450,172 +561,61 @@ export const login = async ( }; }; -export interface ResetPasswordRequest { - token: string; - password: string; -} +// 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 }> => { + return (await api.POST("/auth/refresh", data)) as { + data: TokenPair; + error: Nullable; + }; +}; + +// 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 }> => { + return (await api.POST("/auth/register", data)) as { + data: UserShort; + error: Nullable; + }; +}; + +// 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 }> => { + return (await api.POST("/auth/password/forgot", data)) as { + data: SimpleResponse; + error: Nullable; + }; +}; export interface TokenPair { access_token: string; refresh_token: string; } -export interface ForgotPasswordRequest { - email: string; -} - export interface LoginRequest { username: string; password: string; } +export interface ForgotPasswordRequest { + email: string; +} + export interface RefreshRequest { refresh_token: string; } -// -// package user -// - -// 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 }> => { - return (await api.PUT("/users/:uuid", data)) as { - data: UserProfile; - error: Nullable; - }; -}; - -// 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 }> => { - return (await api.DELETE(`/users/${uuid}`)) as { - data: SimpleResponse; - error: Nullable; - }; -}; - -// 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 }> => { - return (await api.GET(`/users/${uuid}`)) as { - data: UserProfile; - error: Nullable; - }; -}; - -// 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 }> => { - return (await api.POST("/users", data)) as { - data: UserProfile; - error: Nullable; - }; -}; - -export interface UpdateUserRequest { - name: string; - email: string; +export interface ResetPasswordRequest { + token: string; password: string; - roles: models.UserRoles; - status: models.UserStatus; - types: models.UserTypes; - avatar: Nullable; - details: Nullable; - preferences: Nullable; } - -// -// 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; - details: Nullable; - preferences: Nullable; -} - -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; - preferences: Nullable; - avatar: Nullable; -} - -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; diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 46bbf9e..bd2783c 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -11,13 +11,13 @@ import ( "syscall" "time" - "server/internal/auth" - "server/internal/authorization" "server/internal/config" "server/internal/db" + "server/internal/migrations" + "server/internal/roles" "server/internal/routes" + "server/internal/tokens" - "server/internal/mail" "server/internal/seed" "github.com/gofiber/fiber/v3" @@ -36,11 +36,11 @@ func main() { seedCount := flag.Int("seed", 0, "seed N fake users at startup (0 to skip)") flag.Parse() - configPath := envOrDefault("CONFIG_PATH", "configs/config.json") - cfg, err := config.LoadConfig(configPath) + cfg, err := config.LoadConfig() if err != nil { log.Fatalf("load config: %v", err) } + if secret := os.Getenv("AUTH_SECRET"); secret != "" { cfg.Auth.Secret = secret } @@ -54,34 +54,16 @@ func main() { log.Fatalf("init db: %v", err) } - authService, err := auth.NewAuthService(auth.Config{ - Secret: cfg.Auth.Secret, - 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) + if err := migrations.AutoMigrate(dbConn); err != nil { + log.Fatalf("migrate user: %v", err) } - mailService, err := mail.New(mail.Config{ - 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, - }, + tockenService, _ := tokens.NewTockenService(tokens.Config{ + Secret: "your-secret-key", + Issuer: "your-issuer", + AccessTokenExpiry: time.Hour, + RefreshTokenExpiry: 24 * time.Hour, }) - if err != nil { - log.Fatalf("setup mail: %v", err) - } app := fiber.New(fiber.Config{ AppName: cfg.AppName, @@ -125,9 +107,9 @@ func main() { return c.Next() }) - app.Use(authorization.RequireEndpointPermission(authService, dbConn)) + app.Use(roles.RequireEndpointPermission(dbConn, tockenService)) - routes.Register(app, authService, mailService) + routes.Register(app) port := envOrDefault("PORT", "3000") diff --git a/backend/configs/roles.json b/backend/configs/roles.json deleted file mode 100644 index eb83bfb..0000000 --- a/backend/configs/roles.json +++ /dev/null @@ -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" - } -} diff --git a/backend/internal/admin/controller.go b/backend/internal/admin/controller.go index a8f5f7c..9608e3b 100644 --- a/backend/internal/admin/controller.go +++ b/backend/internal/admin/controller.go @@ -7,9 +7,9 @@ import ( "github.com/gofiber/fiber/v3" "gorm.io/gorm" - "server/internal/helpers" - "server/internal/models" + "server/internal/db" "server/internal/responses" + users "server/internal/user" "server/internal/validation" ) @@ -46,12 +46,12 @@ func (ac *AdminController) ListUsers(c fiber.Ctx) error { req.PageSize = 20 } - db, err := helpers.DBFromCtx(c) + db, err := db.DBFromCtx(c) if err != nil { return err } - var list []models.User + var list []users.User offset := (req.Page - 1) * req.PageSize if err := db.Preload("Details").Preload("Preferences"). Limit(req.PageSize). @@ -60,17 +60,11 @@ func (ac *AdminController) ListUsers(c fiber.Ctx) error { 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{ "data": fiber.Map{ "page": req.Page, "pageSize": req.PageSize, - "items": short, + "items": list, }, "error": nil, }) @@ -86,7 +80,7 @@ func (ac *AdminController) BlockUser(c fiber.Ctx) error { return err } - db, err := helpers.DBFromCtx(c) + db, err := db.DBFromCtx(c) if err != nil { return err } @@ -96,8 +90,8 @@ func (ac *AdminController) BlockUser(c fiber.Ctx) error { return fiber.NewError(fiber.StatusBadRequest, "invalid user uuid") } - var user models.User - if err := db.Preload("Details").Preload("Preferences").Where("uuid = ?", uuid).First(&user).Error; err != nil { + var u users.User + if err := db.Preload("Details").Preload("Preferences").Where("uuid = ?", uuid).First(&u).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusNotFound, "user not found") } @@ -106,17 +100,17 @@ func (ac *AdminController) BlockUser(c fiber.Ctx) error { switch req.Action { case "block": - user.Status = models.UserStatusDisabled + u.Status = users.UserStatusDisabled case "unblock": - user.Status = models.UserStatusActive + u.Status = users.UserStatusActive default: 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 c.JSON(responses.Success(models.ToUserShort(&user))) + return c.JSON(responses.Success(u)) } diff --git a/backend/internal/admin/routes.go b/backend/internal/admin/routes.go index ad88ccf..d4dc70c 100644 --- a/backend/internal/admin/routes.go +++ b/backend/internal/admin/routes.go @@ -1,7 +1,7 @@ package admin import ( - "server/internal/authorization" + "server/internal/roles" "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 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 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)) } diff --git a/backend/internal/auth/controller.go b/backend/internal/auth/controller.go deleted file mode 100644 index 3e0dd02..0000000 --- a/backend/internal/auth/controller.go +++ /dev/null @@ -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 -} diff --git a/backend/internal/auth/model.go b/backend/internal/auth/model.go deleted file mode 100644 index 2cbcc7a..0000000 --- a/backend/internal/auth/model.go +++ /dev/null @@ -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"` -} diff --git a/backend/internal/auth/routes.go b/backend/internal/auth/routes.go deleted file mode 100644 index de9a98d..0000000 --- a/backend/internal/auth/routes.go +++ /dev/null @@ -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) -} diff --git a/backend/internal/auth/service.go b/backend/internal/auth/service.go deleted file mode 100644 index f2ca1f9..0000000 --- a/backend/internal/auth/service.go +++ /dev/null @@ -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) -} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 3ae30bf..5c0b2c0 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -42,7 +42,17 @@ type SMTPMailConfig struct { Password string `json:"password"` } -func LoadConfig(path string) (ServerConfig, error) { +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) if err != nil { return ServerConfig{}, fmt.Errorf("read config: %w", err) diff --git a/backend/internal/db/db.go b/backend/internal/db/db.go index c415e38..1a689f0 100644 --- a/backend/internal/db/db.go +++ b/backend/internal/db/db.go @@ -6,8 +6,7 @@ import ( "path/filepath" "strings" - "server/internal/models" - + "github.com/gofiber/fiber/v3" "gorm.io/driver/postgres" "gorm.io/driver/sqlite" "gorm.io/gorm" @@ -18,6 +17,8 @@ type Config struct { DSN string } +var DB *gorm.DB + // Init opens the configured database connection and runs schema migrations. func Init(cfg Config) (*gorm.DB, error) { switch cfg.Driver { @@ -29,18 +30,14 @@ func Init(cfg Config) (*gorm.DB, error) { if err != nil { 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 nil, fmt.Errorf("migrate user: %w", err) - } + DB = db return db, nil case "postgres": db, err := gorm.Open(postgres.Open(cfg.DSN), &gorm.Config{}) if err != nil { 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 nil, fmt.Errorf("migrate user: %w", err) - } + DB = db return db, nil default: return nil, fmt.Errorf("unsupported driver %q", cfg.Driver) @@ -62,3 +59,17 @@ func ensureSQLiteDir(dsn string) error { } 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) +} diff --git a/backend/internal/helpers/services.go b/backend/internal/helpers/services.go deleted file mode 100644 index 3d6d897..0000000 --- a/backend/internal/helpers/services.go +++ /dev/null @@ -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) -} diff --git a/backend/internal/mail/service.go b/backend/internal/mail/service.go index 57164f1..d88593b 100644 --- a/backend/internal/mail/service.go +++ b/backend/internal/mail/service.go @@ -6,10 +6,12 @@ import ( "crypto/tls" "fmt" "html/template" + "log" "net" "net/smtp" "os" "path/filepath" + "server/internal/config" "strings" texttemplate "text/template" "time" @@ -52,7 +54,29 @@ type TemplateData struct { ResetToken string } -func New(cfg Config) (*Service, error) { +func New() (*Service, error) { + + serverCfg, err := config.LoadConfig() + if err != nil { + log.Fatalf("load config: %v", 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" { return nil, fmt.Errorf("unsupported mail mode %q", cfg.Mode) } diff --git a/backend/internal/migrations/init.go b/backend/internal/migrations/init.go new file mode 100644 index 0000000..10d2401 --- /dev/null +++ b/backend/internal/migrations/init.go @@ -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 +} diff --git a/backend/internal/authorization/controller.go b/backend/internal/roles/permissions.go similarity index 74% rename from backend/internal/authorization/controller.go rename to backend/internal/roles/permissions.go index 9fafd94..43c2375 100644 --- a/backend/internal/authorization/controller.go +++ b/backend/internal/roles/permissions.go @@ -1,18 +1,14 @@ -package authorization +package roles import ( - "errors" "fmt" - "server/internal/auth" - "server/internal/models" + "server/internal/tokens" "strings" "github.com/gofiber/fiber/v3" "gorm.io/gorm" ) -var Endpoints map[string]int - type Permission int type Role struct { @@ -38,6 +34,8 @@ var Roles = []Role{ {"guest", GuestPermission}, } +var Endpoints map[string]int + func init() { Endpoints = make(map[string]int) } @@ -48,7 +46,7 @@ func RegisterEndpoint(key string, permission int) { // RequireEndpointPermission enforces permission mapping defined in role config. // 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 { 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())] @@ -61,22 +59,14 @@ func RequireEndpointPermission(authService *auth.AuthService, dbConn *gorm.DB) f return fiber.NewError(fiber.StatusUnauthorized, "missing token header") } - claims, err := authService.ValidateAccessToken(tokenString) + claims, err := tokenService.ValidateAccessToken(tokenString) if err != nil { return fiber.NewError(fiber.StatusUnauthorized, err.Error()) } 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 - if user.Roles == nil { + if claims.Role == "" { return fiber.NewError(fiber.StatusForbidden, "insufficient permissions") } diff --git a/backend/internal/routes/register.go b/backend/internal/routes/register.go index 8371648..39ca61d 100644 --- a/backend/internal/routes/register.go +++ b/backend/internal/routes/register.go @@ -2,10 +2,8 @@ package routes import ( "server/internal/admin" - "server/internal/auth" - "server/internal/mail" "server/internal/systemUtils" - "server/internal/user" + users "server/internal/user" "github.com/gofiber/fiber/v3" ) @@ -21,9 +19,8 @@ type FormResponse struct { Test string `json:"test"` } -func Register(app *fiber.App, authService *auth.AuthService, mailService *mail.Service) { +func Register(app *fiber.App) { systemUtils.RegisterSystemRoutes(app) - auth.Register(app, authService, mailService) - user.RegisterUserRoutes(app, authService) + users.RegisterUserRoutes(app) admin.RegisterAdminRoutes(app) } diff --git a/backend/internal/seed/seed.go b/backend/internal/seed/seed.go index f3b4f3a..56d1c69 100644 --- a/backend/internal/seed/seed.go +++ b/backend/internal/seed/seed.go @@ -4,13 +4,14 @@ import ( "crypto/rand" "encoding/hex" "fmt" + "time" "github.com/brianvoe/gofakeit/v6" "gorm.io/gorm" - "server/internal/auth" - "server/internal/models" + "server/internal/systemUtils" + users "server/internal/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. -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 { return nil, nil, fmt.Errorf("seed size must be greater than zero") } gofakeit.Seed(time.Now().UnixNano()) - items := make([]models.User, 0, n) + items := make([]users.User, 0, n) creds := make([]Credential, 0, n) for i := 0; i < n; i++ { now := time.Now().UTC() @@ -38,20 +39,20 @@ func SeedUsers(db *gorm.DB, n int) ([]models.User, []Credential, error) { if err != nil { return nil, nil, fmt.Errorf("generate password: %w", err) } - passwordHash, err := auth.HashPassword(pw) + passwordHash, err := systemUtils.HashPassword(pw) if err != nil { return nil, nil, fmt.Errorf("hash seed password: %w", err) } - item := models.User{ + item := users.User{ Email: email, Name: gofakeit.Name(), Password: passwordHash, - Roles: models.UserRoles{"user"}, - Status: models.UserStatusActive, - Types: models.UserTypes{"internal"}, + Roles: users.UserRoles{"user"}, + Status: users.UserStatusActive, + Types: users.UserTypes{"internal"}, UUID: uuid, - Details: &models.UserDetails{ + Details: &users.UserDetails{ Title: gofakeit.JobTitle(), FirstName: gofakeit.FirstName(), LastName: gofakeit.LastName(), @@ -61,7 +62,7 @@ func SeedUsers(db *gorm.DB, n int) ([]models.User, []Credential, error) { ZipCode: gofakeit.Zip(), Country: gofakeit.Country(), }, - Preferences: &models.UserPreferences{ + Preferences: &users.UserPreferences{ UseIdle: gofakeit.Bool(), IdleTimeout: gofakeit.Number(1, 30), UseIdlePassword: gofakeit.Bool(), diff --git a/backend/internal/auth/password.go b/backend/internal/systemUtils/password.go similarity index 97% rename from backend/internal/auth/password.go rename to backend/internal/systemUtils/password.go index d603004..700b5ba 100644 --- a/backend/internal/auth/password.go +++ b/backend/internal/systemUtils/password.go @@ -1,4 +1,4 @@ -package auth +package systemUtils import ( "errors" diff --git a/backend/internal/systemUtils/utils.go b/backend/internal/systemUtils/utils.go new file mode 100644 index 0000000..4e54bd2 --- /dev/null +++ b/backend/internal/systemUtils/utils.go @@ -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 +} diff --git a/backend/internal/tokens/services.go b/backend/internal/tokens/services.go index f762833..d5c256b 100644 --- a/backend/internal/tokens/services.go +++ b/backend/internal/tokens/services.go @@ -1,10 +1,68 @@ package tokens import ( + "crypto/rand" "crypto/sha256" + "encoding/base64" "encoding/hex" + "errors" + "time" + + "github.com/gofiber/fiber/v3" + "github.com/golang-jwt/jwt/v5" ) +type TockenService struct { + cfg Config + secret []byte + accessExpiry time.Duration + refreshExpiry time.Duration +} + +type Config struct { + Secret string + Issuer string + AccessTokenExpiry time.Duration + RefreshTokenExpiry 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" +) + +func NewTockenService(cfg Config) (*TockenService, 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 &TockenService{ + cfg: cfg, + secret: []byte(cfg.Secret), + accessExpiry: cfg.AccessTokenExpiry, + refreshExpiry: cfg.RefreshTokenExpiry, + }, nil +} + func hashToken(token string) string { sum := sha256.Sum256([]byte(token)) return hex.EncodeToString(sum[:]) @@ -13,3 +71,131 @@ func hashToken(token string) string { func HashToken(token string) string { 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 +} diff --git a/backend/internal/tsgenerator/generator.go b/backend/internal/tsgenerator/generator.go index b8468f0..25312ff 100644 --- a/backend/internal/tsgenerator/generator.go +++ b/backend/internal/tsgenerator/generator.go @@ -36,7 +36,7 @@ func TsGenerate() (string, error) { if configPath == "" { configPath = "configs/config.json" } - if _, err := config.LoadConfig(configPath); err != nil { + if _, err := config.LoadConfig(); err != nil { return "", fmt.Errorf("load config from %s: %w", configPath, err) } diff --git a/backend/internal/user/controller.go b/backend/internal/user/controller.go index ac9bc85..c8d363c 100644 --- a/backend/internal/user/controller.go +++ b/backend/internal/user/controller.go @@ -1,38 +1,47 @@ -package user +package users import ( + "encoding/json" "errors" + "fmt" "strconv" + "strings" "time" "github.com/gofiber/fiber/v3" "github.com/google/uuid" "gorm.io/gorm" - "server/internal/auth" - "server/internal/helpers" - "server/internal/models" + "server/internal/db" + "server/internal/mail" "server/internal/responses" + "server/internal/systemUtils" + "server/internal/tokens" + "server/internal/validation" ) -type UserController struct{} +type UserController struct { + TockenService *tokens.TockenService +} -func NewUserController() *UserController { - return &UserController{} +func NewUserController(tockenService *tokens.TockenService) *UserController { + return &UserController{ + TockenService: tockenService, + } } // Typescript: interface type UpdateUserRequest struct { - Name string `json:"name" validate:"required,min=1,max=255"` - Email string `json:"email" validate:"required,email"` - Password string `json:"password" validate:"omitempty,min=8,max=128"` - Roles models.UserRoles `json:"roles"` - Status models.UserStatus `json:"status"` - Types models.UserTypes `json:"types"` - Avatar *string `json:"avatar"` - Details *models.UserDetailsShort `json:"details"` - Preferences *models.UserPreferencesShort `json:"preferences"` + Name string `json:"name" validate:"required,min=1,max=255"` + Email string `json:"email" validate:"required,email"` + Password string `json:"password" validate:"omitempty,min=8,max=128"` + Roles UserRoles `json:"roles"` + Status UserStatus `json:"status"` + Types UserTypes `json:"types"` + Avatar *string `json:"avatar"` + Details *UserDetails `json:"details"` + Preferences *UserPreferences `json:"preferences"` } // GetUser returns a single user by UUID. @@ -41,12 +50,12 @@ func (uc *UserController) GetUser(c fiber.Ctx) error { if err != nil { 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. func (uc *UserController) CreateUser(c fiber.Ctx) error { - var req models.UserCreateInput + var req UserCreateInput if err := c.Bind().Body(&req); err != nil { return fiber.NewError(fiber.StatusBadRequest, "invalid payload") } @@ -54,50 +63,50 @@ func (uc *UserController) CreateUser(c fiber.Ctx) error { return err } - db, err := helpers.DBFromCtx(c) + db, err := db.DBFromCtx(c) if err != nil { return err } - var existing models.User + 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") } - hashedPassword, err := auth.HashPassword(req.Password) + hashedPassword, err := systemUtils.HashPassword(req.Password) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "failed to secure password") } now := time.Now().UTC() - user := models.User{ + user := User{ Email: req.Email, Name: req.Name, Password: hashedPassword, - Roles: func() models.UserRoles { + Roles: func() UserRoles { if len(req.Roles) == 0 { - return models.UserRoles{"user"} + return UserRoles{"user"} } return req.Roles }(), - Status: func() models.UserStatus { + Status: func() UserStatus { if req.Status == "" { - return models.UserStatusPending + return UserStatusPending } return req.Status }(), - Types: func() models.UserTypes { + Types: func() UserTypes { if len(req.Types) == 0 { - return models.UserTypes{"internal"} + return UserTypes{"internal"} } return req.Types }(), Avatar: req.Avatar, UUID: uuid.NewString(), - Details: helpers.ToUserDetails(req.Details), - Preferences: helpers.ToUserPreferences(req.Preferences), + Details: req.Details, + Preferences: req.Preferences, CreatedAt: 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 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. @@ -123,7 +132,7 @@ func (uc *UserController) UpdateUser(c fiber.Ctx) error { return err } - db, err := helpers.DBFromCtx(c) + db, err := db.DBFromCtx(c) if err != nil { return err } @@ -134,7 +143,7 @@ func (uc *UserController) UpdateUser(c fiber.Ctx) error { } 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 { return fiber.NewError(fiber.StatusConflict, "user already exists") } 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 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. func (uc *UserController) DeleteUser(c fiber.Ctx) error { - db, err := helpers.DBFromCtx(c) + db, err := db.DBFromCtx(c) if err != nil { 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 := 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 } - 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 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"})) } -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")) if err != nil || id <= 0 { return nil, fiber.NewError(fiber.StatusBadRequest, "invalid user id") } - db, err := helpers.DBFromCtx(c) + db, err := db.DBFromCtx(c) if err != nil { return nil, err } - var user models.User + var user User if err := db.Preload("Details").Preload("Preferences").First(&user, id).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "user not found") @@ -228,18 +567,18 @@ func loadUserByID(c fiber.Ctx) (*models.User, error) { return &user, nil } -func loadUserByUUID(c fiber.Ctx) (*models.User, error) { +func loadUserByUUID(c fiber.Ctx) (*User, error) { uuid := c.Params("uuid") if uuid == "" { return nil, fiber.NewError(fiber.StatusBadRequest, "invalid user uuid") } - db, err := helpers.DBFromCtx(c) + db, err := db.DBFromCtx(c) if err != nil { 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 errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "user not found") @@ -250,15 +589,15 @@ func loadUserByUUID(c fiber.Ctx) (*models.User, error) { 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 { - 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 errors.Is(err, gorm.ErrRecordNotFound) { - details = models.UserDetails{UserID: userID} + details = UserDetails{UserID: userID} } else { return err } @@ -279,15 +618,15 @@ func syncUserDetails(tx *gorm.DB, userID int, input *models.UserDetailsShort) er 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 { - 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 errors.Is(err, gorm.ErrRecordNotFound) { - preferences = models.UserPreferences{UserID: userID} + preferences = UserPreferences{UserID: userID} } else { 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). func (uc *UserController) Me(c fiber.Ctx) error { - claims, ok := auth.ClaimsFromCtx(c) + claims, ok := systemUtils.ClaimsFromCtx(c) if !ok { return fiber.NewError(fiber.StatusUnauthorized, "missing claims") } - db, err := helpers.DBFromCtx(c) + db, err := db.DBFromCtx(c) if err != nil { 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 errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusNotFound, "user not found") @@ -328,5 +667,16 @@ func (uc *UserController) Me(c fiber.Ctx) error { 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(refreshToken string) (tokens.TokenPair, error) { + claims, err := us.TockenService.ParseToken(refreshToken) + if err != nil { + return tokens.TokenPair{}, err + } + if claims.TokenType != tokens.TokenTypeRefresh { + return tokens.TokenPair{}, errors.New("refresh token required") + } + return us.TockenService.GenerateTokenPair(claims.Username) } diff --git a/backend/internal/models/user.go b/backend/internal/user/model.go similarity index 65% rename from backend/internal/models/user.go rename to backend/internal/user/model.go index 1bc2e30..4103e33 100644 --- a/backend/internal/models/user.go +++ b/backend/internal/user/model.go @@ -1,4 +1,4 @@ -package models +package users import ( "time" @@ -11,9 +11,6 @@ import ( // Typescript: type type UserRoles []string -// Typescript: type -type UsersShort []UserShort - type User struct { ID int `json:"id" gorm:"primaryKey"` Email string `json:"email" gorm:"uniqueIndex;size:255"` @@ -36,51 +33,20 @@ type User struct { // Typescript: interface type UserCreateInput struct { - Name string `json:"name" validate:"required,min=1,max=255"` - Email string `json:"email" validate:"required,email"` - Password string `json:"password" validate:"required,min=8,max=128"` - Roles UserRoles `json:"roles"` - Status UserStatus `json:"status"` - Types UserTypes `json:"types"` - Avatar *string `json:"avatar"` - Details *UserDetailsShort `json:"details" ` - Preferences *UserPreferencesShort `json:"preferences" ` + Name string `json:"name" validate:"required,min=1,max=255"` + Email string `json:"email" validate:"required,email"` + Password string `json:"password" validate:"required,min=8,max=128"` + Roles UserRoles `json:"roles"` + Status UserStatus `json:"status"` + Types UserTypes `json:"types"` + Avatar *string `json:"avatar"` + Details *UserDetails `json:"details" ` + Preferences *UserPreferences `json:"preferences" ` } // UserTypes is stored as JSON array (e.g. ["internal","external"]). 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. // // 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. type UserPreferences struct { ID int `json:"id" gorm:"primaryKey"` @@ -174,18 +106,6 @@ type UserPreferences struct { // 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. type UserDetails struct { ID int `json:"id" gorm:"primaryKey"` @@ -204,18 +124,6 @@ type UserDetails struct { // 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. type Session struct { @@ -252,3 +160,25 @@ const ( UserStatusActive UserStatus = "active" 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"` +} diff --git a/backend/internal/user/routes.go b/backend/internal/user/routes.go index d183d45..c6c192c 100644 --- a/backend/internal/user/routes.go +++ b/backend/internal/user/routes.go @@ -1,28 +1,61 @@ -package user +package users import ( - "server/internal/auth" - "server/internal/authorization" + "server/internal/roles" + "server/internal/tokens" + "time" "github.com/gofiber/fiber/v3" + "github.com/gofiber/fiber/v3/middleware/limiter" ) -func RegisterUserRoutes(app *fiber.App, authService *auth.AuthService) { - userController := NewUserController() +func RegisterUserRoutes(app *fiber.App) { + tockenService, _ := tokens.NewTockenService(tokens.Config{ + Secret: "your-secret-key", + Issuer: "your-issuer", + AccessTokenExpiry: time.Hour, + RefreshTokenExpiry: 24 * time.Hour, + }) + + authRateLimiter := limiter.New(limiter.Config{ + Max: 10, + Expiration: time.Minute, + LimiterMiddleware: limiter.SlidingWindow{}, + }) + + userController := NewUserController(tockenService) // Typescript: TSEndpoint= path=/users/:uuid; name=getUser; method=GET; response=models.UserProfile - app.Get("/users/:uuid", authService.Middleware(), userController.GetUser) + app.Get("/users/:uuid", tockenService.Middleware(), userController.GetUser) // Typescript: TSEndpoint= path=/users; name=createUser; method=POST; request=models.UserCreateInput; response=models.UserProfile - app.Post("/users", authService.Middleware(), userController.CreateUser) + app.Post("/users", tockenService.Middleware(), userController.CreateUser) // Typescript: TSEndpoint= path=/users/:uuid; name=updateUser; method=PUT; request=controllers.UpdateUserRequest; response=models.UserProfile - app.Put("/users/:uuid", authService.Middleware(), userController.UpdateUser) + app.Put("/users/:uuid", tockenService.Middleware(), userController.UpdateUser) // Typescript: TSEndpoint= path=/users/:uuid; name=deleteUser; method=DELETE; response=controllers.SimpleResponse - app.Delete("/users/:uuid", authService.Middleware(), userController.DeleteUser) + app.Delete("/users/:uuid", tockenService.Middleware(), userController.DeleteUser) // Typescript: TSEndpoint= path=/auth/me; name=me; method=GET; response=models.UserShort - app.Get("/auth/me", authService.Middleware(), userController.Me) - authorization.RegisterEndpoint("GET/auth/me", int(authorization.UserPermission)) + 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=model.LoginRequest; response=model.TokenPair + app.Post("/auth/login", authRateLimiter, userController.Login) + + // Typescript: TSEndpoint= path=/auth/refresh; name=refresh; method=POST; request=model.RefreshRequest; response=model.TokenPair + app.Post("/auth/refresh", authRateLimiter, userController.Refresh) + + // Typescript: TSEndpoint= path=/auth/register; name=register; method=POST; request=models.UserCreateInput; response=models.UserShort + app.Post("/auth/register", authRateLimiter, userController.Register) + + // Typescript: TSEndpoint= path=/auth/password/forgot; name=forgotPassword; method=POST; request=model.ForgotPasswordRequest; response=controllers.SimpleResponse + app.Post("/auth/password/forgot", authRateLimiter, userController.ForgotPassword) + + // Typescript: TSEndpoint= path=/auth/password/reset; name=resetPassword; method=POST; request=model.ResetPasswordRequest; response=controllers.SimpleResponse + app.Post("/auth/password/reset", authRateLimiter, userController.ResetPassword) + + // Typescript: TSEndpoint= path=/auth/password/valid; name=validToken; method=POST; request=string; response=controllers.SimpleResponse + app.Post("/auth/password/valid", authRateLimiter, userController.ValidToken) }