Compare commits

...

2 Commits

Author SHA1 Message Date
fabio b3741f86c8 Add signup and email templates for user registration and password reset
- Created a new HTML file for the signup page at `backend/spa/signup/index.html`.
- Added HTML and plain text templates for password reset emails at `backend/templates/mailTemplates/password_reset.html.tmpl` and `backend/templates/mailTemplates/password_reset.txt.tmpl`.
- Added HTML and plain text templates for registration confirmation emails at `backend/templates/mailTemplates/registration.html.tmpl` and `backend/templates/mailTemplates/registration.txt.tmpl`.
2026-04-05 20:46:35 +02:00
fabio 36fca2af6c Refactor authentication module: Introduce AuthController and endpoints, implement login, registration, password reset, and token management functionalities. Update routes and services to utilize new structures and improve code organization. Enhance user management with detailed error handling and session management. Update API response types and ensure consistent naming conventions across the application. 2026-04-05 17:09:01 +02:00
135 changed files with 808 additions and 984 deletions

View File

@ -1,3 +1,4 @@
# go-quasar-partial-ssr
bakend in GO frontend quasar framework con generazione delle pagine statiche per la parte public
bakend in GO frontend quasar framework con generazione delle pagine statiche per la parte public

View File

@ -4,7 +4,7 @@
//
// This file was generated by github.com/millevolte/ts-rpc
//
// Mar 17, 2026 18:16:42 UTC
// Apr 05, 2026 20:12:24 UTC
//
export interface ApiRestResponse {
@ -281,58 +281,11 @@ export type Nullable<T> = T | null;
export type Record<K extends string | number | symbol, T> = { [P in K]: T };
//
// package routes
// package systemUtils
//
// Typescript: TSEndpoint= path=/users/:uuid; name=getUser; method=GET; response=models.UserProfile
// internal/http/routes/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=/maildebug; name=mailDebug; method=GET; response=routes.[]MailDebugItem
// internal/http/routes/system_routes.go Line: 48
export const mailDebug = async (): Promise<{
data: MailDebugItem[];
error: Nullable<string>;
}> => {
return (await api.GET("/maildebug")) as {
data: MailDebugItem[];
error: Nullable<string>;
};
};
// Typescript: TSEndpoint= path=/auth/login; name=login; method=POST; request=controllers.LoginRequest; response=auth.TokenPair
// internal/http/routes/auth_routes.go Line: 22
export const login = async (
data: LoginRequest,
): Promise<{ data: TokenPair; error: Nullable<string> }> => {
return (await api.POST("/auth/login", data)) as {
data: TokenPair;
error: Nullable<string>;
};
};
// Typescript: TSEndpoint= path=/auth/register; name=register; method=POST; request=models.UserCreateInput; response=models.UserShort
// internal/http/routes/auth_routes.go Line: 31
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=/metrics; name=metrics; method=GET; response=string
// internal/http/routes/system_routes.go Line: 37
// internal/systemUtils/routes.go Line: 37
export const metrics = async (): Promise<{
data: string;
error: Nullable<string>;
@ -343,32 +296,20 @@ export const metrics = async (): Promise<{
};
};
// Typescript: TSEndpoint= path=/users/:uuid; name=updateUser; method=PUT; request=controllers.UpdateUserRequest; response=models.UserProfile
// internal/http/routes/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=/auth/password/valid; name=validToken; method=POST; request=string; response=controllers.SimpleResponse
// internal/http/routes/auth_routes.go Line: 40
export const validToken = async (
data: string,
): Promise<{ data: SimpleResponse; error: Nullable<string> }> => {
return (await api.POST("/auth/password/valid", data)) as {
data: SimpleResponse;
// Typescript: TSEndpoint= path=/maildebug; name=mailDebug; method=GET; response=routes.[]MailDebugItem
// internal/systemUtils/routes.go Line: 48
export const mailDebug = async (): Promise<{
data: MailDebugItem[];
error: Nullable<string>;
}> => {
return (await api.GET("/maildebug")) as {
data: MailDebugItem[];
error: Nullable<string>;
};
};
// Typescript: TSEndpoint= path=/health; name=health; method=GET; response=string
// internal/http/routes/system_routes.go Line: 34
// internal/systemUtils/routes.go Line: 34
export const health = async (): Promise<{
data: string;
error: Nullable<string>;
@ -379,44 +320,54 @@ export const health = async (): Promise<{
};
};
// Typescript: TSEndpoint= path=/users/:uuid; name=deleteUser; method=DELETE; response=controllers.SimpleResponse
// internal/http/routes/user_routes.go Line: 22
export interface MailDebugItem {
name: string;
content: string;
}
export const deleteUser = async (
uuid: string,
): Promise<{ data: SimpleResponse; error: Nullable<string> }> => {
return (await api.DELETE(`/users/${uuid}`)) as {
data: SimpleResponse;
//
// package admin
//
// 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>;
};
};
// Typescript: TSEndpoint= path=/auth/password/reset; name=resetPassword; method=POST; request=controllers.ResetPasswordRequest; response=controllers.SimpleResponse
// internal/http/routes/auth_routes.go Line: 37
// 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 resetPassword = async (
data: ResetPasswordRequest,
): Promise<{ data: SimpleResponse; error: Nullable<string> }> => {
return (await api.POST("/auth/password/reset", data)) as {
data: SimpleResponse;
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=/users; name=createUser; method=POST; request=models.UserCreateInput; response=models.UserProfile
// internal/http/routes/user_routes.go Line: 16
export interface BlockUserRequest {
action: string;
}
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 ListUsersRequest {
page: number;
pageSize: number;
}
// Typescript: TSEndpoint= path=/auth/refresh; name=refresh; method=POST; request=controllers.RefreshRequest; response=auth.TokenPair
// internal/http/routes/auth_routes.go Line: 25
//
// 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,
@ -428,7 +379,7 @@ export const refresh = async (
};
// Typescript: TSEndpoint= path=/auth/me; name=me; method=GET; response=models.UserShort
// internal/http/routes/auth_routes.go Line: 28
// internal/auth/routes.go Line: 26
export const me = async (): Promise<{
data: UserShort;
error: Nullable<string>;
@ -439,32 +390,20 @@ export const me = async (): Promise<{
};
};
// Typescript: TSEndpoint= path=/admin/users; name=listUsers; method=POST; request=controllers.ListUsersRequest; response=models.[]UserShort
// internal/http/routes/admin_routes.go Line: 12
// Typescript: TSEndpoint= path=/auth/register; name=register; method=POST; request=models.UserCreateInput; response=models.UserShort
// internal/auth/routes.go Line: 29
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=controllers.BlockUserRequest; response=models.UserShort
// internal/http/routes/admin_routes.go Line: 15
export const blockUser = async (
data: BlockUserRequest,
export const register = async (
data: UserCreateInput,
): Promise<{ data: UserShort; error: Nullable<string> }> => {
return (await api.PUT("/admin/users/:uuid/block", data)) as {
return (await api.POST("/auth/register", data)) as {
data: UserShort;
error: Nullable<string>;
};
};
// Typescript: TSEndpoint= path=/auth/password/forgot; name=forgotPassword; method=POST; request=controllers.ForgotPasswordRequest; response=controllers.SimpleResponse
// internal/http/routes/auth_routes.go Line: 34
// 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,
@ -475,6 +414,140 @@ export const forgotPassword = async (
};
};
// Typescript: TSEndpoint= path=/auth/password/reset; name=resetPassword; method=POST; request=model.ResetPasswordRequest; response=controllers.SimpleResponse
// internal/auth/routes.go Line: 35
export const resetPassword = async (
data: ResetPasswordRequest,
): Promise<{ data: SimpleResponse; error: Nullable<string> }> => {
return (await api.POST("/auth/password/reset", data)) as {
data: SimpleResponse;
error: Nullable<string>;
};
};
// Typescript: TSEndpoint= path=/auth/password/valid; name=validToken; method=POST; request=string; response=controllers.SimpleResponse
// internal/auth/routes.go Line: 38
export const validToken = async (
data: string,
): Promise<{ data: SimpleResponse; error: Nullable<string> }> => {
return (await api.POST("/auth/password/valid", data)) as {
data: SimpleResponse;
error: Nullable<string>;
};
};
// Typescript: TSEndpoint= path=/auth/login; name=login; method=POST; request=model.LoginRequest; response=model.TokenPair
// internal/auth/routes.go Line: 20
export const login = async (
data: LoginRequest,
): Promise<{ data: TokenPair; error: Nullable<string> }> => {
return (await api.POST("/auth/login", data)) as {
data: TokenPair;
error: Nullable<string>;
};
};
export interface ResetPasswordRequest {
token: string;
password: string;
}
export interface TokenPair {
access_token: string;
refresh_token: string;
}
export interface ForgotPasswordRequest {
email: string;
}
export interface LoginRequest {
username: string;
password: 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<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;
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;
@ -484,11 +557,6 @@ export interface FormResponse {
test: string;
}
export interface MailDebugItem {
name: string;
content: string;
}
//
// package models
//
@ -505,6 +573,17 @@ export interface UserCreateInput {
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;
@ -527,16 +606,7 @@ export interface UserShort {
avatar: Nullable<string>;
}
export interface UserPreferencesShort {
useIdle: boolean;
idleTimeout: number;
useIdlePassword: boolean;
idlePin: string;
useDirectLogin: boolean;
useQuadcodeLogin: boolean;
sendNoticesMail: boolean;
language: string;
}
export type UserTypes = string[];
export type UsersShort = UserShort[];
@ -544,66 +614,8 @@ export type UserRoles = string[];
export type UserStatus = (typeof EnumUserStatus)[keyof typeof EnumUserStatus];
export type UserTypes = string[];
export const EnumUserStatus = {
UserStatusPending: "pending",
UserStatusActive: "active",
UserStatusDisabled: "disabled",
} as const;
//
// package controllers
//
export interface ResetPasswordRequest {
token: string;
password: string;
}
export interface RefreshRequest {
refresh_token: string;
}
export interface SimpleResponse {
message: 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>;
}
export interface BlockUserRequest {
action: string;
}
export interface ForgotPasswordRequest {
email: string;
}
export interface ListUsersRequest {
page: number;
pageSize: number;
}
export interface LoginRequest {
username: string;
password: string;
}
//
// package auth
//
export interface TokenPair {
access_token: string;
refresh_token: string;
}

View File

@ -12,12 +12,12 @@ import (
"time"
"server/internal/auth"
"server/internal/authorization"
"server/internal/config"
"server/internal/db"
"server/internal/http/controllers"
"server/internal/http/routes"
"server/internal/routes"
"server/internal/mail"
"server/internal/roles"
"server/internal/seed"
"github.com/gofiber/fiber/v3"
@ -54,7 +54,7 @@ func main() {
log.Fatalf("init db: %v", err)
}
authService, err := auth.New(auth.Config{
authService, err := auth.NewAuthService(auth.Config{
Secret: cfg.Auth.Secret,
Issuer: cfg.Auth.Issuer,
AccessTokenExpiry: time.Duration(cfg.Auth.AccessTokenExpiryMinutes) * time.Minute,
@ -83,19 +83,6 @@ func main() {
log.Fatalf("setup mail: %v", err)
}
roleConfigPath := cfg.RolesConfigPath
if envRoleConfig := os.Getenv("ROLES_CONFIG_PATH"); envRoleConfig != "" {
roleConfigPath = envRoleConfig
}
if roleConfigPath == "" {
roleConfigPath = envOrDefault("ROLES_CONFIG_PATH", "configs/roles.json")
}
roleResolver, err := controllers.LoadRoleConfig(roleConfigPath)
if err != nil {
log.Fatalf("load role config: %v", err)
}
roles.CheckUserRoleConsistency(dbConn, roleResolver)
app := fiber.New(fiber.Config{
AppName: cfg.AppName,
ReadTimeout: time.Duration(cfg.ReadTimeoutSeconds) * time.Second,
@ -138,7 +125,8 @@ func main() {
return c.Next()
})
app.Use(controllers.RequireEndpointPermission(roleResolver, authService))
app.Use(authorization.RequireEndpointPermission(authService, dbConn))
routes.Register(app, authService, mailService)
port := envOrDefault("PORT", "3000")

View File

@ -14,7 +14,8 @@
"mode": "file",
"from": "noreply@example.local",
"debug_dir": "data/mail-debug",
"templates_dir": "internal/http/templates",
"templates_dir": "templates",
"mail_templates_dir": "templates/mailTemplates",
"frontend_base_url": "http://localhost:9000",
"reset_password_path": "/#reset-password",
"smtp": {

Binary file not shown.

View File

@ -1,4 +1,4 @@
package controllers
package admin
import (
"errors"
@ -7,7 +7,10 @@ import (
"github.com/gofiber/fiber/v3"
"gorm.io/gorm"
"server/internal/helpers"
"server/internal/models"
"server/internal/responses"
"server/internal/validation"
)
type AdminController struct{}
@ -33,7 +36,7 @@ func (ac *AdminController) ListUsers(c fiber.Ctx) error {
if err := c.Bind().Body(&req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
}
if err := validateStruct(&req); err != nil {
if err := validation.ValidateStruct(&req); err != nil {
return err
}
if req.Page <= 0 {
@ -43,7 +46,7 @@ func (ac *AdminController) ListUsers(c fiber.Ctx) error {
req.PageSize = 20
}
db, err := dbFromCtx(c)
db, err := helpers.DBFromCtx(c)
if err != nil {
return err
}
@ -79,11 +82,11 @@ func (ac *AdminController) BlockUser(c fiber.Ctx) error {
if err := c.Bind().Body(&req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
}
if err := validateStruct(&req); err != nil {
if err := validation.ValidateStruct(&req); err != nil {
return err
}
db, err := dbFromCtx(c)
db, err := helpers.DBFromCtx(c)
if err != nil {
return err
}
@ -115,5 +118,5 @@ func (ac *AdminController) BlockUser(c fiber.Ctx) error {
return fiber.NewError(fiber.StatusInternalServerError, "failed to update user status")
}
return c.JSON(success(models.ToUserShort(&user)))
return c.JSON(responses.Success(models.ToUserShort(&user)))
}

View File

@ -0,0 +1,19 @@
package admin
import (
"server/internal/authorization"
"github.com/gofiber/fiber/v3"
)
func RegisterAdminRoutes(app *fiber.App) {
adminController := NewAdminController()
// 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))
// 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))
}

View File

@ -1,4 +1,4 @@
package controllers
package auth
import (
"crypto/rand"
@ -6,69 +6,44 @@ import (
"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"
"gorm.io/gorm"
"server/internal/auth"
"server/internal/mail"
"server/internal/models"
"github.com/google/uuid"
"gorm.io/gorm"
)
type AuthController struct {
authService *auth.Service
authService *AuthService
mailService *mail.Service
}
// Typescript: interface
type SimpleResponse struct {
Message string `json:"message"`
}
func NewAuthController(authService *auth.Service, mailService *mail.Service) *AuthController {
func New(authService *AuthService, mailService *mail.Service) *AuthController {
return &AuthController{
authService: authService,
mailService: mailService,
}
}
// 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"`
}
// 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 := validateStruct(&req); err != nil {
if err := validation.ValidateStruct(&req); err != nil {
return err
}
db, err := dbFromCtx(c)
db, err := helpers.DBFromCtx(c)
if err != nil {
return err
}
@ -80,7 +55,7 @@ func (ac *AuthController) Login(c fiber.Ctx) error {
}
return fiber.NewError(fiber.StatusInternalServerError, "failed to fetch user")
}
match, err := auth.VerifyPassword(user.Password, req.Password)
match, err := VerifyPassword(user.Password, req.Password)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to verify credentials")
}
@ -88,7 +63,7 @@ func (ac *AuthController) Login(c fiber.Ctx) error {
return fiber.NewError(fiber.StatusUnauthorized, "invalid credentials")
}
tokens, err := ac.authService.GenerateTokenPair(user.Email)
token, err := ac.authService.GenerateTokenPair(user.Email)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to issue token")
}
@ -102,8 +77,8 @@ func (ac *AuthController) Login(c fiber.Ctx) error {
session := models.Session{
UserID: &userID,
Username: user.Email,
AccessTokenHash: hashToken(tokens.AccessToken),
RefreshTokenHash: hashToken(tokens.RefreshToken),
AccessTokenHash: tokens.HashToken(token.AccessToken),
RefreshTokenHash: tokens.HashToken(token.RefreshToken),
ExpiresAt: now.Add(ac.authService.RefreshExpiry()),
IPAddress: c.IP(),
UserAgent: c.Get("User-Agent"),
@ -113,10 +88,9 @@ func (ac *AuthController) Login(c fiber.Ctx) error {
return fiber.NewError(fiber.StatusInternalServerError, "failed to record session")
}
//c.Set("Auth-Token", tokens.AccessToken)
c.Response().Header.Set("Auth-Token", tokens.AccessToken)
c.Response().Header.Set("Auth-Token", token.AccessToken)
return c.JSON(success(tokens))
return c.JSON(responses.Success(token))
}
// Refresh renews an access/refresh token pair using a valid refresh token.
@ -133,30 +107,7 @@ func (ac *AuthController) Refresh(c fiber.Ctx) error {
if err != nil {
return fiber.NewError(fiber.StatusUnauthorized, err.Error())
}
return c.JSON(success(tokens))
}
// Me returns the authenticated user's profile (short format).
func (ac *AuthController) Me(c fiber.Ctx) error {
claims, ok := auth.ClaimsFromCtx(c)
if !ok {
return fiber.NewError(fiber.StatusUnauthorized, "missing claims")
}
db, err := dbFromCtx(c)
if err != nil {
return err
}
var user models.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")
}
return fiber.NewError(fiber.StatusInternalServerError, "failed to load user")
}
return c.JSON(success(models.ToUserShort(&user)))
return c.JSON(responses.Success(tokens))
}
// Register creates a new user with optional roles/types/preferences.
@ -165,11 +116,11 @@ func (ac *AuthController) Register(c fiber.Ctx) error {
if err := c.Bind().Body(&req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
}
if err := validateStruct(&req); err != nil {
if err := validation.ValidateStruct(&req); err != nil {
return err
}
db, err := dbFromCtx(c)
db, err := helpers.DBFromCtx(c)
if err != nil {
return err
}
@ -182,7 +133,7 @@ func (ac *AuthController) Register(c fiber.Ctx) error {
}
now := time.Now().UTC()
hashedPassword, err := auth.HashPassword(req.Password)
hashedPassword, err := HashPassword(req.Password)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to secure password")
}
@ -210,7 +161,7 @@ func (ac *AuthController) Register(c fiber.Ctx) error {
}(),
Avatar: req.Avatar,
UUID: uuid.NewString(),
Details: toUserDetails(req.Details),
Details: helpers.ToUserDetails(req.Details),
Preferences: func() *models.UserPreferences {
if req.Preferences == nil {
return nil
@ -247,7 +198,7 @@ func (ac *AuthController) Register(c fiber.Ctx) error {
return fiber.NewError(fiber.StatusInternalServerError, "failed to send registration email")
}
return c.Status(fiber.StatusCreated).JSON(success(models.ToUserShort(&user)))
return c.Status(fiber.StatusCreated).JSON(responses.Success(models.ToUserShort(&user)))
}
func (ac *AuthController) ForgotPassword(c fiber.Ctx) error {
@ -255,11 +206,11 @@ func (ac *AuthController) ForgotPassword(c fiber.Ctx) error {
if err := c.Bind().Body(&req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
}
if err := validateStruct(&req); err != nil {
if err := validation.ValidateStruct(&req); err != nil {
return err
}
db, err := dbFromCtx(c)
db, err := helpers.DBFromCtx(c)
if err != nil {
return err
}
@ -267,13 +218,13 @@ func (ac *AuthController) ForgotPassword(c fiber.Ctx) error {
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(success(fiber.Map{"sent": true}))
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(success(fiber.Map{"sent": true}))
return c.JSON(responses.Success(fiber.Map{"sent": true}))
}
resetToken, err := generateSecureToken()
@ -284,7 +235,7 @@ func (ac *AuthController) ForgotPassword(c fiber.Ctx) error {
now := time.Now().UTC()
record := models.PasswordResetToken{
UserID: user.ID,
TokenHash: hashToken(resetToken),
TokenHash: tokens.HashToken(resetToken),
ExpiresAt: now.Add(30 * time.Minute),
CreatedAt: now,
UpdatedAt: now,
@ -315,7 +266,7 @@ func (ac *AuthController) ForgotPassword(c fiber.Ctx) error {
return fiber.NewError(fiber.StatusInternalServerError, "failed to send reset email")
}
return c.JSON(success(SimpleResponse{Message: "password reset email sent"}))
return c.JSON(responses.Success(responses.SimpleResponse{Message: "password reset email sent"}))
}
func (ac *AuthController) ResetPassword(c fiber.Ctx) error {
@ -323,22 +274,22 @@ func (ac *AuthController) ResetPassword(c fiber.Ctx) error {
if err := c.Bind().Body(&req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
}
if err := validateStruct(&req); err != nil {
if err := validation.ValidateStruct(&req); err != nil {
return err
}
db, err := dbFromCtx(c)
db, err := helpers.DBFromCtx(c)
if err != nil {
return err
}
hashedPassword, err := auth.HashPassword(req.Password)
hashedPassword, err := HashPassword(req.Password)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to secure password")
}
now := time.Now().UTC()
tokenHash := hashToken(req.Token)
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 {
@ -378,7 +329,7 @@ func (ac *AuthController) ResetPassword(c fiber.Ctx) error {
return fiber.NewError(fiber.StatusInternalServerError, "failed to reset password")
}
return c.JSON(success(SimpleResponse{Message: "password reset successful"}))
return c.JSON(responses.Success(responses.SimpleResponse{Message: "password reset successful"}))
}
func (ac *AuthController) ValidToken(c fiber.Ctx) error {
@ -387,7 +338,6 @@ func (ac *AuthController) ValidToken(c fiber.Ctx) error {
return fiber.NewError(fiber.StatusBadRequest, "token is required")
}
// Accept both plain text token payload and JSON string payload.
token := raw
if strings.HasPrefix(raw, "\"") && strings.HasSuffix(raw, "\"") {
if err := json.Unmarshal([]byte(raw), &token); err != nil {
@ -399,13 +349,13 @@ func (ac *AuthController) ValidToken(c fiber.Ctx) error {
return fiber.NewError(fiber.StatusBadRequest, "token is required")
}
db, err := dbFromCtx(c)
db, err := helpers.DBFromCtx(c)
if err != nil {
return err
}
now := time.Now().UTC()
tokenHash := hashToken(token)
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) {
@ -418,7 +368,7 @@ func (ac *AuthController) ValidToken(c fiber.Ctx) error {
return fiber.NewError(fiber.StatusBadRequest, "invalid or expired reset token")
}
return c.JSON(success(SimpleResponse{Message: "valid reset token"}))
return c.JSON(responses.Success(responses.SimpleResponse{Message: "valid reset token"}))
}
func generateSecureToken() (string, error) {

View File

@ -0,0 +1,26 @@
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

@ -0,0 +1,23 @@
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,21 +0,0 @@
package auth
type Permission int
type Role struct {
Name string
Permissions Permission
}
const (
AdminPermission Permission = 0xff - (1<<iota - 1)
ManagerPermission
UserPermission
GuestPermission
)
var Roles = []Role{
{"admin", AdminPermission},
{"manager", ManagerPermission},
{"user", UserPermission},
{"guest", GuestPermission},
}

View File

@ -1,40 +1,35 @@
package routes
package auth
import (
"time"
"server/internal/auth"
"server/internal/http/controllers"
"server/internal/mail"
"github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/middleware/limiter"
)
func registerAuthRoutes(app *fiber.App, authService *auth.Service, mailService *mail.Service) {
authController := controllers.NewAuthController(authService, mailService)
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=controllers.LoginRequest; response=auth.TokenPair
// 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=controllers.RefreshRequest; response=auth.TokenPair
// 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/me; name=me; method=GET; response=models.UserShort
app.Get("/auth/me", authService.Middleware(), authController.Me)
// 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=controllers.ForgotPasswordRequest; response=controllers.SimpleResponse
// 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=controllers.ResetPasswordRequest; response=controllers.SimpleResponse
// 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

View File

@ -2,45 +2,25 @@ package auth
import (
"errors"
"strings"
"time"
"github.com/gofiber/fiber/v3"
"github.com/golang-jwt/jwt/v5"
)
type Config struct {
Secret string
Issuer string
AccessTokenExpiry time.Duration
RefreshTokenExpiry time.Duration
}
type Service struct {
type AuthService struct {
cfg Config
secret []byte
accessExpiry time.Duration
refreshExpiry 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"`
}
const (
tokenTypeAccess = "access"
tokenTypeRefresh = "refresh"
)
func New(cfg Config) (*Service, error) {
func NewAuthService(cfg Config) (*AuthService, error) {
if cfg.Secret == "" {
return nil, errors.New("jwt secret is required")
}
@ -51,7 +31,7 @@ func New(cfg Config) (*Service, error) {
return nil, errors.New("refresh token expiry must be positive")
}
return &Service{
return &AuthService{
cfg: cfg,
secret: []byte(cfg.Secret),
accessExpiry: cfg.AccessTokenExpiry,
@ -59,7 +39,7 @@ func New(cfg Config) (*Service, error) {
}, nil
}
func (s *Service) GenerateTokenPair(username string) (TokenPair, error) {
func (s *AuthService) GenerateTokenPair(username string) (TokenPair, error) {
access, err := s.generateToken(username, tokenTypeAccess, s.accessExpiry)
if err != nil {
return TokenPair{}, err
@ -76,17 +56,15 @@ func (s *Service) GenerateTokenPair(username string) (TokenPair, error) {
}, nil
}
// AccessExpiry returns the configured access token lifetime.
func (s *Service) AccessExpiry() time.Duration {
func (s *AuthService) AccessExpiry() time.Duration {
return s.accessExpiry
}
// RefreshExpiry returns the configured refresh token lifetime.
func (s *Service) RefreshExpiry() time.Duration {
func (s *AuthService) RefreshExpiry() time.Duration {
return s.refreshExpiry
}
func (s *Service) Middleware() fiber.Handler {
func (s *AuthService) Middleware() fiber.Handler {
return func(c fiber.Ctx) error {
tokenString := c.Get("Auth-Token")
if tokenString == "" {
@ -106,7 +84,7 @@ func (s *Service) Middleware() fiber.Handler {
}
}
func (s *Service) Refresh(refreshToken string) (TokenPair, error) {
func (s *AuthService) Refresh(refreshToken string) (TokenPair, error) {
claims, err := s.parseToken(refreshToken)
if err != nil {
return TokenPair{}, err
@ -117,8 +95,7 @@ func (s *Service) Refresh(refreshToken string) (TokenPair, error) {
return s.GenerateTokenPair(claims.Username)
}
// ValidateAccessToken parses and validates an access token string, ensuring type=access.
func (s *Service) ValidateAccessToken(tokenString string) (*Claims, error) {
func (s *AuthService) ValidateAccessToken(tokenString string) (*Claims, error) {
claims, err := s.parseToken(tokenString)
if err != nil {
return nil, err
@ -129,7 +106,16 @@ func (s *Service) ValidateAccessToken(tokenString string) (*Claims, error) {
return claims, nil
}
func (s *Service) parseToken(tokenString string) (*Claims, error) {
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 {
@ -147,7 +133,7 @@ func (s *Service) parseToken(tokenString string) (*Claims, error) {
return claims, nil
}
func (s *Service) generateToken(username, tokenType string, expiry time.Duration) (string, error) {
func (s *AuthService) generateToken(username, tokenType string, expiry time.Duration) (string, error) {
claims := Claims{
Username: username,
TokenType: tokenType,
@ -161,27 +147,3 @@ func (s *Service) generateToken(username, tokenType string, expiry time.Duration
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(s.secret)
}
func bearerToken(header string) (string, error) {
if header == "" {
return "", errors.New("missing Auth-Token header")
}
if !strings.HasPrefix(header, "Bearer ") {
return "", errors.New("invalid Authorization header format")
}
token := strings.TrimSpace(strings.TrimPrefix(header, "Bearer "))
if token == "" {
return "", errors.New("empty bearer token")
}
return token, 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
}

View File

@ -0,0 +1,85 @@
package authorization
import (
"errors"
"fmt"
"server/internal/auth"
"server/internal/models"
"strings"
"github.com/gofiber/fiber/v3"
"gorm.io/gorm"
)
var Endpoints map[string]int
type Permission int
type Role struct {
Name string
Permissions Permission
}
const (
SuperAdminPermission Permission = 0b1111111111111111
AdminPermission Permission = 0b0111111111111111
ManagerPermission Permission = 0b0010111111111111
ContentCreatorPermission Permission = 0b0001111111111111
UserPermission Permission = 0b0000000000000011
GuestPermission Permission = 0b0000000000000001
)
var Roles = []Role{
{"superadmin", SuperAdminPermission},
{"admin", AdminPermission},
{"manager", ManagerPermission},
{"content_creator", ContentCreatorPermission},
{"user", UserPermission},
{"guest", GuestPermission},
}
func init() {
Endpoints = make(map[string]int)
}
func RegisterEndpoint(key string, permission int) {
Endpoints[key] = permission
}
// 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 {
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())]
if perm == 0 {
return c.Next()
}
tokenString := c.Get("Auth-Token")
if tokenString == "" {
return fiber.NewError(fiber.StatusUnauthorized, "missing token header")
}
claims, err := authService.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 {
return fiber.NewError(fiber.StatusForbidden, "insufficient permissions")
}
return c.Next()
}
}

View File

@ -29,6 +29,7 @@ type MailConfig struct {
From string `json:"from"`
DebugDir string `json:"debug_dir"`
TemplatesDir string `json:"templates_dir"`
MailTemplatesDir string `json:"mail_templates_dir"`
FrontendBaseURL string `json:"frontend_base_url"`
ResetPasswordPath string `json:"reset_password_path"`
SMTP SMTPMailConfig `json:"smtp"`
@ -62,8 +63,8 @@ func LoadConfig(path string) (ServerConfig, error) {
if cfg.Mail.Mode == "" {
cfg.Mail.Mode = "file"
}
if cfg.Mail.TemplatesDir == "" {
cfg.Mail.TemplatesDir = "internal/http/templates"
if cfg.Mail.MailTemplatesDir == "" {
cfg.Mail.MailTemplatesDir = "templates/mailTemplates"
}
if cfg.Mail.ResetPasswordPath == "" {
cfg.Mail.ResetPasswordPath = "/#reset-password"

View File

@ -1,10 +1,10 @@
package controllers
package helpers
import (
"server/internal/models"
"github.com/gofiber/fiber/v3"
"gorm.io/gorm"
"server/internal/models"
)
// dbFromCtx extracts *gorm.DB from Fiber context.
@ -17,6 +17,10 @@ func dbFromCtx(c fiber.Ctx) (*gorm.DB, error) {
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
@ -33,6 +37,10 @@ func toUserDetails(d *models.UserDetailsShort) *models.UserDetails {
}
}
func ToUserDetails(d *models.UserDetailsShort) *models.UserDetails {
return toUserDetails(d)
}
func toUserPreferences(p *models.UserPreferencesShort) *models.UserPreferences {
if p == nil {
return nil
@ -48,3 +56,7 @@ func toUserPreferences(p *models.UserPreferencesShort) *models.UserPreferences {
Language: p.Language,
}
}
func ToUserPreferences(p *models.UserPreferencesShort) *models.UserPreferences {
return toUserPreferences(p)
}

View File

@ -1,223 +0,0 @@
package controllers
import (
"encoding/json"
"errors"
"fmt"
"os"
"strings"
"github.com/gofiber/fiber/v3"
"gorm.io/gorm"
"server/internal/auth"
"server/internal/models"
)
type RoleConfig struct {
Roles map[string][]string `json:"roles"`
Permissions map[string][]string `json:"permissions"`
Endpoints map[string]string `json:"endpoints"`
}
type RoleResolver struct {
roleClosure map[string]map[string]struct{}
permMap map[string]map[string]struct{}
endpointPerm map[string]string
}
func LoadRoleConfig(path string) (*RoleResolver, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read role config: %w", err)
}
var cfg RoleConfig
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parse role config: %w", err)
}
res := &RoleResolver{
roleClosure: make(map[string]map[string]struct{}),
permMap: make(map[string]map[string]struct{}),
endpointPerm: make(map[string]string),
}
for role := range cfg.Roles {
res.roleClosure[role] = make(map[string]struct{})
}
// Compute role closure (role implies itself).
var dfs func(string, map[string]struct{})
dfs = func(role string, seen map[string]struct{}) {
if _, ok := seen[role]; ok {
return
}
seen[role] = struct{}{}
if implied, ok := cfg.Roles[role]; ok {
for _, r := range implied {
dfs(r, seen)
}
}
}
for role := range cfg.Roles {
set := make(map[string]struct{})
set[role] = struct{}{}
dfs(role, set)
res.roleClosure[role] = set
}
// Build permission map including inherited permissions.
for role := range cfg.Roles {
res.permMap[role] = make(map[string]struct{})
}
for role := range cfg.Roles {
closure := res.roleClosure[role]
for implied := range closure {
for _, p := range cfg.Permissions[implied] {
res.permMap[role][p] = struct{}{}
}
}
}
// Normalise endpoints to "METHOD /path".
for key, perm := range cfg.Endpoints {
parts := strings.SplitN(key, " ", 2)
if len(parts) != 2 {
return nil, fmt.Errorf("invalid endpoint key %q", key)
}
method := strings.TrimSpace(strings.ToUpper(parts[0]))
path := strings.TrimSpace(parts[1])
res.endpointPerm[method+" "+path] = perm
}
return res, nil
}
func (r *RoleResolver) HasRole(userRoles []string, required string) bool {
for _, ur := range userRoles {
if closure, ok := r.roleClosure[ur]; ok {
if _, present := closure[required]; present {
return true
}
}
}
return false
}
func (r *RoleResolver) HasPermission(userRoles []string, perm string) bool {
for _, ur := range userRoles {
if perms, ok := r.permMap[ur]; ok {
if _, present := perms[perm]; present {
return true
}
}
}
return false
}
func (r *RoleResolver) PermissionForEndpoint(method, path string) (string, bool) {
key := strings.ToUpper(method) + " " + path
perm, ok := r.endpointPerm[key]
return perm, ok
}
func (r *RoleResolver) RoleDefined(role string) bool {
_, ok := r.roleClosure[role]
return ok
}
// RequireRole ensures the authenticated user has the specified role (with inheritance).
func RequireRole(resolver *RoleResolver, role string) fiber.Handler {
return func(c fiber.Ctx) error {
claims, ok := auth.ClaimsFromCtx(c)
if !ok {
return fiber.NewError(fiber.StatusUnauthorized, "missing claims")
}
db, err := dbFromCtx(c)
if err != nil {
return err
}
var user models.User
if err := db.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")
}
if !resolver.HasRole(user.Roles, role) {
return fiber.NewError(fiber.StatusForbidden, "insufficient permissions")
}
return c.Next()
}
}
// RequirePermission ensures the authenticated user has the given permission.
func RequirePermission(resolver *RoleResolver, perm string) fiber.Handler {
return func(c fiber.Ctx) error {
claims, ok := auth.ClaimsFromCtx(c)
if !ok {
return fiber.NewError(fiber.StatusUnauthorized, "missing claims")
}
db, err := dbFromCtx(c)
if err != nil {
return err
}
var user models.User
if err := db.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")
}
if !resolver.HasPermission(user.Roles, perm) {
return fiber.NewError(fiber.StatusForbidden, "insufficient permissions")
}
return c.Next()
}
}
// RequireEndpointPermission enforces permission mapping defined in role config.
// If the endpoint is not configured, or mapped to "*", it allows the request.
func RequireEndpointPermission(resolver *RoleResolver, authService *auth.Service) fiber.Handler {
return func(c fiber.Ctx) error {
perm, ok := resolver.PermissionForEndpoint(c.Method(), c.Path())
if !ok || perm == "*" {
return c.Next()
}
tokenString := c.Get("Auth-Token")
if tokenString == "" {
return fiber.NewError(fiber.StatusUnauthorized, "missing token header")
}
claims, err := authService.ValidateAccessToken(tokenString)
if err != nil {
return fiber.NewError(fiber.StatusUnauthorized, err.Error())
}
c.Locals("authClaims", claims)
db, err := dbFromCtx(c)
if err != nil {
return err
}
var user models.User
if err := db.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")
}
if !resolver.HasPermission(user.Roles, perm) {
return fiber.NewError(fiber.StatusForbidden, "insufficient permissions")
}
return c.Next()
}
}

View File

@ -1,17 +0,0 @@
package routes
import (
"server/internal/http/controllers"
"github.com/gofiber/fiber/v3"
)
func registerAdminRoutes(app *fiber.App) {
adminController := controllers.NewAdminController()
// Typescript: TSEndpoint= path=/admin/users; name=listUsers; method=POST; request=controllers.ListUsersRequest; response=models.[]UserShort
app.Post("/admin/users", adminController.ListUsers)
// Typescript: TSEndpoint= path=/admin/users/:uuid/block; name=blockUser; method=PUT; request=controllers.BlockUserRequest; response=models.UserShort
app.Put("/admin/users/:uuid/block", adminController.BlockUser)
}

View File

@ -1,26 +0,0 @@
package routes
import (
"server/internal/auth"
"server/internal/mail"
"github.com/gofiber/fiber/v3"
)
// Typescript: interface
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.Service, mailService *mail.Service) {
registerSystemRoutes(app)
registerAuthRoutes(app, authService, mailService)
registerUserRoutes(app, authService)
registerAdminRoutes(app)
}

View File

@ -1,7 +1,12 @@
package controllers
package responses
import "github.com/gofiber/fiber/v3"
// Typescript: interface
type SimpleResponse struct {
Message string `json:"message"`
}
// success wraps a payload in the standard API envelope.
func success(data any) fiber.Map {
return fiber.Map{
@ -9,3 +14,7 @@ func success(data any) fiber.Map {
"error": nil,
}
}
func Success(data any) fiber.Map {
return success(data)
}

View File

@ -1,26 +1,9 @@
package roles
import (
"log"
"gorm.io/gorm"
"server/internal/http/controllers"
"server/internal/models"
)
func CheckUserRoleConsistency(db *gorm.DB, resolver *controllers.RoleResolver) {
var list []models.User
if err := db.Select("email", "roles").Find(&list).Error; err != nil {
log.Printf("warning: cannot verify user roles: %v", err)
return
}
func CheckUserRoleConsistency(db *gorm.DB) {
for _, u := range list {
for _, r := range u.Roles {
if !resolver.RoleDefined(r) {
log.Printf("inconsistency: user %s has undefined role %q", u.Email, r)
}
}
}
}

View File

@ -0,0 +1,29 @@
package routes
import (
"server/internal/admin"
"server/internal/auth"
"server/internal/mail"
"server/internal/systemUtils"
"server/internal/user"
"github.com/gofiber/fiber/v3"
)
// Typescript: interface
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)
auth.Register(app, authService, mailService)
user.RegisterUserRoutes(app, authService)
admin.RegisterAdminRoutes(app)
}

View File

@ -1,4 +1,4 @@
package routes
package systemUtils
import (
"fmt"
@ -14,7 +14,7 @@ import (
"github.com/prometheus/client_golang/prometheus/promhttp"
)
const spaDistPath = "internal/http/static/spa"
const spaDistPath = "http/static/spa"
// Typescript: interface
type MailDebugItem struct {
@ -30,7 +30,7 @@ func healthHandler(c fiber.Ctx) error {
})
}
func registerSystemRoutes(app *fiber.App) {
func RegisterSystemRoutes(app *fiber.App) {
// Typescript: TSEndpoint= path=/health; name=health; method=GET; response=string
app.Get("/health", healthHandler)

View File

@ -1,4 +1,4 @@
package controllers
package tokens
import (
"crypto/sha256"
@ -9,3 +9,7 @@ func hashToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
func HashToken(token string) string {
return hashToken(token)
}

View File

@ -1,4 +1,4 @@
package controllers
package user
import (
"errors"
@ -10,7 +10,10 @@ import (
"gorm.io/gorm"
"server/internal/auth"
"server/internal/helpers"
"server/internal/models"
"server/internal/responses"
"server/internal/validation"
)
type UserController struct{}
@ -38,7 +41,7 @@ func (uc *UserController) GetUser(c fiber.Ctx) error {
if err != nil {
return err
}
return c.JSON(success(models.ToUserProfile(user)))
return c.JSON(responses.Success(models.ToUserProfile(user)))
}
// CreateUser creates a user together with optional details and preferences.
@ -47,11 +50,11 @@ func (uc *UserController) CreateUser(c fiber.Ctx) error {
if err := c.Bind().Body(&req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
}
if err := validateStruct(&req); err != nil {
if err := validation.ValidateStruct(&req); err != nil {
return err
}
db, err := dbFromCtx(c)
db, err := helpers.DBFromCtx(c)
if err != nil {
return err
}
@ -93,8 +96,8 @@ func (uc *UserController) CreateUser(c fiber.Ctx) error {
}(),
Avatar: req.Avatar,
UUID: uuid.NewString(),
Details: toUserDetails(req.Details),
Preferences: toUserPreferences(req.Preferences),
Details: helpers.ToUserDetails(req.Details),
Preferences: helpers.ToUserPreferences(req.Preferences),
CreatedAt: now,
UpdatedAt: now,
}
@ -107,7 +110,7 @@ func (uc *UserController) CreateUser(c fiber.Ctx) error {
return fiber.NewError(fiber.StatusInternalServerError, "failed to reload user")
}
return c.Status(fiber.StatusCreated).JSON(success(models.ToUserProfile(&user)))
return c.Status(fiber.StatusCreated).JSON(responses.Success(models.ToUserProfile(&user)))
}
// UpdateUser replaces user fields and synchronizes details/preferences.
@ -116,11 +119,11 @@ func (uc *UserController) UpdateUser(c fiber.Ctx) error {
if err := c.Bind().Body(&req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
}
if err := validateStruct(&req); err != nil {
if err := validation.ValidateStruct(&req); err != nil {
return err
}
db, err := dbFromCtx(c)
db, err := helpers.DBFromCtx(c)
if err != nil {
return err
}
@ -173,12 +176,12 @@ func (uc *UserController) UpdateUser(c fiber.Ctx) error {
return fiber.NewError(fiber.StatusInternalServerError, "failed to reload user")
}
return c.JSON(success(models.ToUserProfile(user)))
return c.JSON(responses.Success(models.ToUserProfile(user)))
}
// DeleteUser removes a user and linked details/preferences through cascading delete rules.
func (uc *UserController) DeleteUser(c fiber.Ctx) error {
db, err := dbFromCtx(c)
db, err := helpers.DBFromCtx(c)
if err != nil {
return err
}
@ -200,7 +203,7 @@ func (uc *UserController) DeleteUser(c fiber.Ctx) error {
return fiber.NewError(fiber.StatusInternalServerError, "failed to delete user")
}
return c.JSON(success(SimpleResponse{Message: "user deleted"}))
return c.JSON(responses.Success(responses.SimpleResponse{Message: "user deleted"}))
}
func loadUserByID(c fiber.Ctx) (*models.User, error) {
@ -209,7 +212,7 @@ func loadUserByID(c fiber.Ctx) (*models.User, error) {
return nil, fiber.NewError(fiber.StatusBadRequest, "invalid user id")
}
db, err := dbFromCtx(c)
db, err := helpers.DBFromCtx(c)
if err != nil {
return nil, err
}
@ -231,7 +234,7 @@ func loadUserByUUID(c fiber.Ctx) (*models.User, error) {
return nil, fiber.NewError(fiber.StatusBadRequest, "invalid user uuid")
}
db, err := dbFromCtx(c)
db, err := helpers.DBFromCtx(c)
if err != nil {
return nil, err
}
@ -304,3 +307,26 @@ func syncUserPreferences(tx *gorm.DB, userID int, input *models.UserPreferencesS
}
return tx.Save(&preferences).Error
}
// Me returns the authenticated user's profile (short format).
func (uc *UserController) Me(c fiber.Ctx) error {
claims, ok := auth.ClaimsFromCtx(c)
if !ok {
return fiber.NewError(fiber.StatusUnauthorized, "missing claims")
}
db, err := helpers.DBFromCtx(c)
if err != nil {
return err
}
var user models.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")
}
return fiber.NewError(fiber.StatusInternalServerError, "failed to load user")
}
return c.JSON(responses.Success(models.ToUserShort(&user)))
}

View File

@ -1,14 +1,14 @@
package routes
package user
import (
"server/internal/auth"
"server/internal/http/controllers"
"server/internal/authorization"
"github.com/gofiber/fiber/v3"
)
func registerUserRoutes(app *fiber.App, authService *auth.Service) {
userController := controllers.NewUserController()
func RegisterUserRoutes(app *fiber.App, authService *auth.AuthService) {
userController := NewUserController()
// Typescript: TSEndpoint= path=/users/:uuid; name=getUser; method=GET; response=models.UserProfile
app.Get("/users/:uuid", authService.Middleware(), userController.GetUser)
@ -21,4 +21,8 @@ func registerUserRoutes(app *fiber.App, authService *auth.Service) {
// Typescript: TSEndpoint= path=/users/:uuid; name=deleteUser; method=DELETE; response=controllers.SimpleResponse
app.Delete("/users/:uuid", authService.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))
}

View File

@ -1,4 +1,4 @@
package controllers
package validation
import (
"fmt"
@ -26,3 +26,7 @@ func validateStruct(payload any) error {
}
return nil
}
func ValidateStruct(payload any) error {
return validateStruct(payload)
}

View File

Before

Width:  |  Height:  |  Size: 317 KiB

After

Width:  |  Height:  |  Size: 317 KiB

View File

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 84 KiB

View File

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 68 KiB

View File

Before

Width:  |  Height:  |  Size: 141 KiB

After

Width:  |  Height:  |  Size: 141 KiB

View File

Before

Width:  |  Height:  |  Size: 448 KiB

After

Width:  |  Height:  |  Size: 448 KiB

View File

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

View File

Before

Width:  |  Height:  |  Size: 8.8 KiB

After

Width:  |  Height:  |  Size: 8.8 KiB

View File

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 37 KiB

View File

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 115 KiB

Some files were not shown because too many files have changed in this diff Show More