Compare commits
No commits in common. "5b9fe6c9b72897b1df6160af622aed0cf6a4ed55" and "b3741f86c8b41804bd11f8de21e7c78930d4dc34" have entirely different histories.
5b9fe6c9b7
...
b3741f86c8
|
|
@ -4,7 +4,7 @@
|
|||
//
|
||||
// This file was generated by github.com/millevolte/ts-rpc
|
||||
//
|
||||
// Apr 06, 2026 16:56:35 UTC
|
||||
// Apr 05, 2026 20:12:24 UTC
|
||||
//
|
||||
|
||||
export interface ApiRestResponse {
|
||||
|
|
@ -280,205 +280,10 @@ export type Nullable<T> = T | null;
|
|||
|
||||
export type Record<K extends string | number | symbol, T> = { [P in K]: T };
|
||||
|
||||
//
|
||||
// package user
|
||||
//
|
||||
|
||||
// Typescript: TSEndpoint= path=/users/:uuid; name=getUser; method=GET; response=models.UserProfile
|
||||
// internal/user/routes.go Line: 13
|
||||
export const getUser = async (
|
||||
uuid: string,
|
||||
): Promise<{ data: UserProfile; error: Nullable<string> }> => {
|
||||
return (await api.GET(`/users/${uuid}`)) as {
|
||||
data: UserProfile;
|
||||
error: Nullable<string>;
|
||||
};
|
||||
};
|
||||
|
||||
// Typescript: TSEndpoint= path=/users; name=createUser; method=POST; request=models.UserCreateInput; response=models.UserProfile
|
||||
// internal/user/routes.go Line: 16
|
||||
|
||||
export const createUser = async (
|
||||
data: UserCreateInput,
|
||||
): Promise<{ data: UserProfile; error: Nullable<string> }> => {
|
||||
return (await api.POST("/users", data)) as {
|
||||
data: UserProfile;
|
||||
error: Nullable<string>;
|
||||
};
|
||||
};
|
||||
|
||||
// Typescript: TSEndpoint= path=/users/:uuid; name=updateUser; method=PUT; request=controllers.UpdateUserRequest; response=models.UserProfile
|
||||
// internal/user/routes.go Line: 19
|
||||
|
||||
export const updateUser = async (
|
||||
data: UpdateUserRequest,
|
||||
): Promise<{ data: UserProfile; error: Nullable<string> }> => {
|
||||
return (await api.PUT("/users/:uuid", data)) as {
|
||||
data: UserProfile;
|
||||
error: Nullable<string>;
|
||||
};
|
||||
};
|
||||
|
||||
// Typescript: TSEndpoint= path=/users/:uuid; name=deleteUser; method=DELETE; response=controllers.SimpleResponse
|
||||
// internal/user/routes.go Line: 22
|
||||
|
||||
export const deleteUser = async (
|
||||
uuid: string,
|
||||
): Promise<{ data: SimpleResponse; error: Nullable<string> }> => {
|
||||
return (await api.DELETE(`/users/${uuid}`)) as {
|
||||
data: SimpleResponse;
|
||||
error: Nullable<string>;
|
||||
};
|
||||
};
|
||||
|
||||
// Typescript: TSEndpoint= path=/auth/me; name=me; method=GET; response=models.UserShort
|
||||
// internal/user/routes.go Line: 25
|
||||
export const me = async (): Promise<{
|
||||
data: UserShort;
|
||||
error: Nullable<string>;
|
||||
}> => {
|
||||
return (await api.GET("/auth/me")) as {
|
||||
data: UserShort;
|
||||
error: Nullable<string>;
|
||||
};
|
||||
};
|
||||
|
||||
export interface UpdateUserRequest {
|
||||
name: string;
|
||||
email: string;
|
||||
password: string;
|
||||
roles: models.UserRoles;
|
||||
status: models.UserStatus;
|
||||
types: models.UserTypes;
|
||||
avatar: Nullable<string>;
|
||||
details: Nullable<models.UserDetailsShort>;
|
||||
preferences: Nullable<models.UserPreferencesShort>;
|
||||
}
|
||||
|
||||
//
|
||||
// package models
|
||||
//
|
||||
|
||||
export interface UserDetailsShort {
|
||||
title: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
address: string;
|
||||
city: string;
|
||||
zipCode: string;
|
||||
country: string;
|
||||
phone: string;
|
||||
}
|
||||
|
||||
export interface UserPreferencesShort {
|
||||
useIdle: boolean;
|
||||
idleTimeout: number;
|
||||
useIdlePassword: boolean;
|
||||
idlePin: string;
|
||||
useDirectLogin: boolean;
|
||||
useQuadcodeLogin: boolean;
|
||||
sendNoticesMail: boolean;
|
||||
language: string;
|
||||
}
|
||||
|
||||
export interface UserShort {
|
||||
email: string;
|
||||
name: string;
|
||||
roles: UserRoles;
|
||||
status: UserStatus;
|
||||
uuid: string;
|
||||
details: Nullable<UserDetailsShort>;
|
||||
preferences: Nullable<UserPreferencesShort>;
|
||||
avatar: Nullable<string>;
|
||||
}
|
||||
|
||||
export interface UserCreateInput {
|
||||
name: string;
|
||||
email: string;
|
||||
password: string;
|
||||
roles: UserRoles;
|
||||
status: UserStatus;
|
||||
types: UserTypes;
|
||||
avatar: Nullable<string>;
|
||||
details: Nullable<UserDetailsShort>;
|
||||
preferences: Nullable<UserPreferencesShort>;
|
||||
}
|
||||
|
||||
export type UserRoles = string[];
|
||||
|
||||
export type UserStatus = (typeof EnumUserStatus)[keyof typeof EnumUserStatus];
|
||||
|
||||
export type UserTypes = string[];
|
||||
|
||||
export type UsersShort = UserShort[];
|
||||
|
||||
export const EnumUserStatus = {
|
||||
UserStatusPending: "pending",
|
||||
UserStatusActive: "active",
|
||||
UserStatusDisabled: "disabled",
|
||||
} as const;
|
||||
|
||||
//
|
||||
// package admin
|
||||
//
|
||||
|
||||
// Typescript: TSEndpoint= path=/admin/users/:uuid/block; name=blockUser; method=PUT; request=admin.BlockUserRequest; response=models.UserShort
|
||||
// internal/admin/routes.go Line: 16
|
||||
|
||||
export const blockUser = async (
|
||||
data: BlockUserRequest,
|
||||
): Promise<{ data: UserShort; error: Nullable<string> }> => {
|
||||
return (await api.PUT("/admin/users/:uuid/block", data)) as {
|
||||
data: UserShort;
|
||||
error: Nullable<string>;
|
||||
};
|
||||
};
|
||||
|
||||
// Typescript: TSEndpoint= path=/admin/users; name=listUsers; method=POST; request=admin.ListUsersRequest; response=models.[]UserShort
|
||||
// internal/admin/routes.go Line: 12
|
||||
|
||||
export const listUsers = async (
|
||||
data: ListUsersRequest,
|
||||
): Promise<{ data: UserShort[]; error: Nullable<string> }> => {
|
||||
return (await api.POST("/admin/users", data)) as {
|
||||
data: UserShort[];
|
||||
error: Nullable<string>;
|
||||
};
|
||||
};
|
||||
|
||||
export interface BlockUserRequest {
|
||||
action: string;
|
||||
}
|
||||
|
||||
export interface ListUsersRequest {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
//
|
||||
// package responses
|
||||
//
|
||||
|
||||
export interface SimpleResponse {
|
||||
message: string;
|
||||
}
|
||||
|
||||
//
|
||||
// package systemUtils
|
||||
//
|
||||
|
||||
// Typescript: TSEndpoint= path=/health; name=health; method=GET; response=string
|
||||
// internal/systemUtils/routes.go Line: 34
|
||||
export const health = async (): Promise<{
|
||||
data: string;
|
||||
error: Nullable<string>;
|
||||
}> => {
|
||||
return (await api.GET("/health")) as {
|
||||
data: string;
|
||||
error: Nullable<string>;
|
||||
};
|
||||
};
|
||||
|
||||
// Typescript: TSEndpoint= path=/metrics; name=metrics; method=GET; response=string
|
||||
// internal/systemUtils/routes.go Line: 37
|
||||
export const metrics = async (): Promise<{
|
||||
|
|
@ -503,31 +308,115 @@ export const mailDebug = async (): Promise<{
|
|||
};
|
||||
};
|
||||
|
||||
// Typescript: TSEndpoint= path=/health; name=health; method=GET; response=string
|
||||
// internal/systemUtils/routes.go Line: 34
|
||||
export const health = async (): Promise<{
|
||||
data: string;
|
||||
error: Nullable<string>;
|
||||
}> => {
|
||||
return (await api.GET("/health")) as {
|
||||
data: string;
|
||||
error: Nullable<string>;
|
||||
};
|
||||
};
|
||||
|
||||
export interface MailDebugItem {
|
||||
name: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
//
|
||||
// package routes
|
||||
// package admin
|
||||
//
|
||||
|
||||
export interface FormRequest {
|
||||
req: string;
|
||||
count: number;
|
||||
// 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=/admin/users/:uuid/block; name=blockUser; method=PUT; request=admin.BlockUserRequest; response=models.UserShort
|
||||
// internal/admin/routes.go Line: 16
|
||||
|
||||
export const blockUser = async (
|
||||
data: BlockUserRequest,
|
||||
): Promise<{ data: UserShort; error: Nullable<string> }> => {
|
||||
return (await api.PUT("/admin/users/:uuid/block", data)) as {
|
||||
data: UserShort;
|
||||
error: Nullable<string>;
|
||||
};
|
||||
};
|
||||
|
||||
export interface BlockUserRequest {
|
||||
action: string;
|
||||
}
|
||||
|
||||
export interface FormResponse {
|
||||
test: string;
|
||||
export interface ListUsersRequest {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
//
|
||||
// package auth
|
||||
//
|
||||
|
||||
// Typescript: TSEndpoint= path=/auth/password/reset; name=resetPassword; method=POST; request=model.ResetPasswordRequest; response=controllers.SimpleResponse
|
||||
// Typescript: TSEndpoint= path=/auth/refresh; name=refresh; method=POST; request=model.RefreshRequest; response=model.TokenPair
|
||||
// internal/auth/routes.go Line: 23
|
||||
|
||||
export const refresh = async (
|
||||
data: RefreshRequest,
|
||||
): Promise<{ data: TokenPair; error: Nullable<string> }> => {
|
||||
return (await api.POST("/auth/refresh", data)) as {
|
||||
data: TokenPair;
|
||||
error: Nullable<string>;
|
||||
};
|
||||
};
|
||||
|
||||
// Typescript: TSEndpoint= path=/auth/me; name=me; method=GET; response=models.UserShort
|
||||
// internal/auth/routes.go Line: 26
|
||||
export const me = async (): Promise<{
|
||||
data: UserShort;
|
||||
error: Nullable<string>;
|
||||
}> => {
|
||||
return (await api.GET("/auth/me")) as {
|
||||
data: UserShort;
|
||||
error: Nullable<string>;
|
||||
};
|
||||
};
|
||||
|
||||
// Typescript: TSEndpoint= path=/auth/register; name=register; method=POST; request=models.UserCreateInput; response=models.UserShort
|
||||
// internal/auth/routes.go Line: 29
|
||||
|
||||
export const register = async (
|
||||
data: UserCreateInput,
|
||||
): Promise<{ data: UserShort; error: Nullable<string> }> => {
|
||||
return (await api.POST("/auth/register", data)) as {
|
||||
data: UserShort;
|
||||
error: Nullable<string>;
|
||||
};
|
||||
};
|
||||
|
||||
// Typescript: TSEndpoint= path=/auth/password/forgot; name=forgotPassword; method=POST; request=model.ForgotPasswordRequest; response=controllers.SimpleResponse
|
||||
// internal/auth/routes.go Line: 32
|
||||
|
||||
export const forgotPassword = async (
|
||||
data: ForgotPasswordRequest,
|
||||
): Promise<{ data: SimpleResponse; error: Nullable<string> }> => {
|
||||
return (await api.POST("/auth/password/forgot", data)) as {
|
||||
data: SimpleResponse;
|
||||
error: Nullable<string>;
|
||||
};
|
||||
};
|
||||
|
||||
// Typescript: TSEndpoint= path=/auth/password/reset; name=resetPassword; method=POST; request=model.ResetPasswordRequest; response=controllers.SimpleResponse
|
||||
// internal/auth/routes.go Line: 35
|
||||
|
||||
export const resetPassword = async (
|
||||
data: ResetPasswordRequest,
|
||||
): Promise<{ data: SimpleResponse; error: Nullable<string> }> => {
|
||||
|
|
@ -538,7 +427,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: 35
|
||||
// internal/auth/routes.go Line: 38
|
||||
|
||||
export const validToken = async (
|
||||
data: string,
|
||||
|
|
@ -561,61 +450,172 @@ export const login = async (
|
|||
};
|
||||
};
|
||||
|
||||
// Typescript: TSEndpoint= path=/auth/refresh; name=refresh; method=POST; request=model.RefreshRequest; response=model.TokenPair
|
||||
// internal/auth/routes.go Line: 23
|
||||
|
||||
export const refresh = async (
|
||||
data: RefreshRequest,
|
||||
): Promise<{ data: TokenPair; error: Nullable<string> }> => {
|
||||
return (await api.POST("/auth/refresh", data)) as {
|
||||
data: TokenPair;
|
||||
error: Nullable<string>;
|
||||
};
|
||||
};
|
||||
|
||||
// Typescript: TSEndpoint= path=/auth/register; name=register; method=POST; request=models.UserCreateInput; response=models.UserShort
|
||||
// internal/auth/routes.go Line: 26
|
||||
|
||||
export const register = async (
|
||||
data: UserCreateInput,
|
||||
): Promise<{ data: UserShort; error: Nullable<string> }> => {
|
||||
return (await api.POST("/auth/register", data)) as {
|
||||
data: UserShort;
|
||||
error: Nullable<string>;
|
||||
};
|
||||
};
|
||||
|
||||
// Typescript: TSEndpoint= path=/auth/password/forgot; name=forgotPassword; method=POST; request=model.ForgotPasswordRequest; response=controllers.SimpleResponse
|
||||
// internal/auth/routes.go Line: 29
|
||||
|
||||
export const forgotPassword = async (
|
||||
data: ForgotPasswordRequest,
|
||||
): Promise<{ data: SimpleResponse; error: Nullable<string> }> => {
|
||||
return (await api.POST("/auth/password/forgot", data)) as {
|
||||
data: SimpleResponse;
|
||||
error: Nullable<string>;
|
||||
};
|
||||
};
|
||||
export interface 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 ForgotPasswordRequest {
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface RefreshRequest {
|
||||
refresh_token: string;
|
||||
}
|
||||
|
||||
export interface ResetPasswordRequest {
|
||||
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;
|
||||
}
|
||||
|
||||
export interface FormResponse {
|
||||
test: string;
|
||||
}
|
||||
|
||||
//
|
||||
// package models
|
||||
//
|
||||
|
||||
export interface UserCreateInput {
|
||||
name: string;
|
||||
email: string;
|
||||
password: string;
|
||||
roles: UserRoles;
|
||||
status: UserStatus;
|
||||
types: UserTypes;
|
||||
avatar: Nullable<string>;
|
||||
details: Nullable<UserDetailsShort>;
|
||||
preferences: Nullable<UserPreferencesShort>;
|
||||
}
|
||||
|
||||
export interface UserPreferencesShort {
|
||||
useIdle: boolean;
|
||||
idleTimeout: number;
|
||||
useIdlePassword: boolean;
|
||||
idlePin: string;
|
||||
useDirectLogin: boolean;
|
||||
useQuadcodeLogin: boolean;
|
||||
sendNoticesMail: boolean;
|
||||
language: string;
|
||||
}
|
||||
|
||||
export interface UserDetailsShort {
|
||||
title: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
address: string;
|
||||
city: string;
|
||||
zipCode: string;
|
||||
country: string;
|
||||
phone: string;
|
||||
}
|
||||
|
||||
export interface UserShort {
|
||||
email: string;
|
||||
name: string;
|
||||
roles: UserRoles;
|
||||
status: UserStatus;
|
||||
uuid: string;
|
||||
details: Nullable<UserDetailsShort>;
|
||||
preferences: Nullable<UserPreferencesShort>;
|
||||
avatar: Nullable<string>;
|
||||
}
|
||||
|
||||
export type UserTypes = string[];
|
||||
|
||||
export type UsersShort = UserShort[];
|
||||
|
||||
export type UserRoles = string[];
|
||||
|
||||
export type UserStatus = (typeof EnumUserStatus)[keyof typeof EnumUserStatus];
|
||||
|
||||
export const EnumUserStatus = {
|
||||
UserStatusPending: "pending",
|
||||
UserStatusActive: "active",
|
||||
UserStatusDisabled: "disabled",
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -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,23 +36,51 @@ func main() {
|
|||
seedCount := flag.Int("seed", 0, "seed N fake users at startup (0 to skip)")
|
||||
flag.Parse()
|
||||
|
||||
cfg, err := config.GetConfig()
|
||||
configPath := envOrDefault("CONFIG_PATH", "configs/config.json")
|
||||
cfg, err := config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
log.Fatalf("config: %v", err)
|
||||
log.Fatalf("load config: %v", err)
|
||||
}
|
||||
if secret := os.Getenv("AUTH_SECRET"); secret != "" {
|
||||
cfg.Auth.Secret = secret
|
||||
}
|
||||
|
||||
dbConn, err := db.GetDB()
|
||||
dbCfg := db.Config{
|
||||
Driver: envOrDefault("DB_driver", "sqlite"),
|
||||
DSN: envOrDefault("DB_dsn", "file:./data/data.db?_foreign_keys=on"),
|
||||
}
|
||||
dbConn, err := db.Init(dbCfg)
|
||||
if err != nil {
|
||||
log.Fatalf("init db: %v", err)
|
||||
}
|
||||
|
||||
if err := migrations.AutoMigrate(dbConn); err != nil {
|
||||
log.Fatalf("migrate user: %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)
|
||||
}
|
||||
|
||||
tokenService, err := tokens.GetTockenService()
|
||||
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,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("init tokens: %v", err)
|
||||
log.Fatalf("setup mail: %v", err)
|
||||
}
|
||||
|
||||
app := fiber.New(fiber.Config{
|
||||
|
|
@ -97,9 +125,9 @@ func main() {
|
|||
return c.Next()
|
||||
})
|
||||
|
||||
app.Use(roles.RequireEndpointPermission(dbConn, tokenService))
|
||||
app.Use(authorization.RequireEndpointPermission(authService, dbConn))
|
||||
|
||||
routes.Register(app)
|
||||
routes.Register(app, authService, mailService)
|
||||
|
||||
port := envOrDefault("PORT", "3000")
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
|
@ -7,9 +7,9 @@ import (
|
|||
"github.com/gofiber/fiber/v3"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"server/internal/db"
|
||||
"server/internal/helpers"
|
||||
"server/internal/models"
|
||||
"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 := db.DBFromCtx(c)
|
||||
db, err := helpers.DBFromCtx(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var list []users.User
|
||||
var list []models.User
|
||||
offset := (req.Page - 1) * req.PageSize
|
||||
if err := db.Preload("Details").Preload("Preferences").
|
||||
Limit(req.PageSize).
|
||||
|
|
@ -60,11 +60,17 @@ 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": list,
|
||||
"items": short,
|
||||
},
|
||||
"error": nil,
|
||||
})
|
||||
|
|
@ -80,7 +86,7 @@ func (ac *AdminController) BlockUser(c fiber.Ctx) error {
|
|||
return err
|
||||
}
|
||||
|
||||
db, err := db.DBFromCtx(c)
|
||||
db, err := helpers.DBFromCtx(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -90,8 +96,8 @@ func (ac *AdminController) BlockUser(c fiber.Ctx) error {
|
|||
return fiber.NewError(fiber.StatusBadRequest, "invalid user uuid")
|
||||
}
|
||||
|
||||
var u users.User
|
||||
if err := db.Preload("Details").Preload("Preferences").Where("uuid = ?", uuid).First(&u).Error; err != nil {
|
||||
var user models.User
|
||||
if err := db.Preload("Details").Preload("Preferences").Where("uuid = ?", uuid).First(&user).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return fiber.NewError(fiber.StatusNotFound, "user not found")
|
||||
}
|
||||
|
|
@ -100,17 +106,17 @@ func (ac *AdminController) BlockUser(c fiber.Ctx) error {
|
|||
|
||||
switch req.Action {
|
||||
case "block":
|
||||
u.Status = users.UserStatusDisabled
|
||||
user.Status = models.UserStatusDisabled
|
||||
case "unblock":
|
||||
u.Status = users.UserStatusActive
|
||||
user.Status = models.UserStatusActive
|
||||
default:
|
||||
return fiber.NewError(fiber.StatusBadRequest, "invalid action")
|
||||
}
|
||||
u.UpdatedAt = time.Now().UTC()
|
||||
user.UpdatedAt = time.Now().UTC()
|
||||
|
||||
if err := db.Save(&u).Error; err != nil {
|
||||
if err := db.Save(&user).Error; err != nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "failed to update user status")
|
||||
}
|
||||
|
||||
return c.JSON(responses.Success(u))
|
||||
return c.JSON(responses.Success(models.ToUserShort(&user)))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"server/internal/roles"
|
||||
"server/internal/authorization"
|
||||
|
||||
"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)
|
||||
roles.RegisterEndpoint("POST/admin/users", int(roles.AdminPermission))
|
||||
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)
|
||||
roles.RegisterEndpoint("PUT/admin/users/:uuid/block", int(roles.AdminPermission))
|
||||
authorization.RegisterEndpoint("PUT/admin/users/:uuid/block", int(authorization.AdminPermission))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,380 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -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"`
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package systemUtils
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
|
@ -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"`
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
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)
|
||||
}
|
||||
|
|
@ -0,0 +1,149 @@
|
|||
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)
|
||||
}
|
||||
|
|
@ -1,14 +1,18 @@
|
|||
package roles
|
||||
package authorization
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"server/internal/tokens"
|
||||
"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 {
|
||||
|
|
@ -34,8 +38,6 @@ var Roles = []Role{
|
|||
{"guest", GuestPermission},
|
||||
}
|
||||
|
||||
var Endpoints map[string]int
|
||||
|
||||
func init() {
|
||||
Endpoints = make(map[string]int)
|
||||
}
|
||||
|
|
@ -46,7 +48,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(dbConn *gorm.DB, tokenService *tokens.TockenService) fiber.Handler {
|
||||
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())]
|
||||
|
|
@ -59,14 +61,22 @@ func RequireEndpointPermission(dbConn *gorm.DB, tokenService *tokens.TockenServi
|
|||
return fiber.NewError(fiber.StatusUnauthorized, "missing token header")
|
||||
}
|
||||
|
||||
claims, err := tokenService.ValidateAccessToken(tokenString)
|
||||
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 claims.Role == "" {
|
||||
if user.Roles == nil {
|
||||
return fiber.NewError(fiber.StatusForbidden, "insufficient permissions")
|
||||
}
|
||||
|
||||
|
|
@ -14,7 +14,6 @@ type ServerConfig struct {
|
|||
DisableStartupMessage bool `json:"disable_startup_message"`
|
||||
Auth AuthConfig `json:"auth"`
|
||||
Mail MailConfig `json:"mail"`
|
||||
Db DbConfig `json:"db_config"`
|
||||
RolesConfigPath string `json:"roles_config_path"`
|
||||
}
|
||||
|
||||
|
|
@ -43,54 +42,23 @@ type SMTPMailConfig struct {
|
|||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type DbConfig struct {
|
||||
Driver string
|
||||
DSN string
|
||||
}
|
||||
|
||||
var Config *ServerConfig = nil
|
||||
|
||||
func GetConfig() (*ServerConfig, error) {
|
||||
if Config == nil {
|
||||
var err error
|
||||
Config, err = loadConfig()
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to load config: %v\n", err)
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return Config, nil
|
||||
}
|
||||
|
||||
func envOrDefault(key, defaultValue string) string {
|
||||
if value, exists := os.LookupEnv(key); exists {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func loadConfig() (*ServerConfig, error) {
|
||||
path := envOrDefault("CONFIG_PATH", "configs/config.json")
|
||||
|
||||
func LoadConfig(path string) (ServerConfig, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read config: %w", err)
|
||||
return ServerConfig{}, fmt.Errorf("read config: %w", err)
|
||||
}
|
||||
var cfg ServerConfig
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("parse config: %w", err)
|
||||
}
|
||||
if secret := os.Getenv("AUTH_SECRET"); secret != "" {
|
||||
cfg.Auth.Secret = secret
|
||||
return ServerConfig{}, fmt.Errorf("parse config: %w", err)
|
||||
}
|
||||
if cfg.Auth.Secret == "" {
|
||||
return nil, fmt.Errorf("auth.secret must be set")
|
||||
return ServerConfig{}, fmt.Errorf("auth.secret must be set")
|
||||
}
|
||||
if cfg.Auth.AccessTokenExpiryMinutes <= 0 {
|
||||
return nil, fmt.Errorf("auth.access_token_expiry_minutes must be greater than zero")
|
||||
return ServerConfig{}, fmt.Errorf("auth.access_token_expiry_minutes must be greater than zero")
|
||||
}
|
||||
if cfg.Auth.RefreshTokenExpiryMinutes <= 0 {
|
||||
return nil, fmt.Errorf("auth.refresh_token_expiry_minutes must be greater than zero")
|
||||
return ServerConfig{}, fmt.Errorf("auth.refresh_token_expiry_minutes must be greater than zero")
|
||||
}
|
||||
if cfg.Mail.Mode == "" {
|
||||
cfg.Mail.Mode = "file"
|
||||
|
|
@ -102,26 +70,21 @@ func loadConfig() (*ServerConfig, error) {
|
|||
cfg.Mail.ResetPasswordPath = "/#reset-password"
|
||||
}
|
||||
if cfg.Mail.Mode != "smtp" && cfg.Mail.Mode != "file" {
|
||||
return nil, fmt.Errorf("mail.mode must be either smtp or file")
|
||||
return ServerConfig{}, fmt.Errorf("mail.mode must be either smtp or file")
|
||||
}
|
||||
if cfg.Mail.From == "" {
|
||||
return nil, fmt.Errorf("mail.from must be set")
|
||||
return ServerConfig{}, fmt.Errorf("mail.from must be set")
|
||||
}
|
||||
if cfg.Mail.Mode == "smtp" {
|
||||
if cfg.Mail.SMTP.Host == "" {
|
||||
return nil, fmt.Errorf("mail.smtp.host must be set when mail.mode=smtp")
|
||||
return ServerConfig{}, fmt.Errorf("mail.smtp.host must be set when mail.mode=smtp")
|
||||
}
|
||||
if cfg.Mail.SMTP.Port <= 0 {
|
||||
return nil, fmt.Errorf("mail.smtp.port must be greater than zero when mail.mode=smtp")
|
||||
return ServerConfig{}, fmt.Errorf("mail.smtp.port must be greater than zero when mail.mode=smtp")
|
||||
}
|
||||
} else if cfg.Mail.DebugDir == "" {
|
||||
cfg.Mail.DebugDir = "data/mail-debug"
|
||||
}
|
||||
|
||||
cfg.Db = DbConfig{
|
||||
Driver: envOrDefault("DB_driver", "sqlite"),
|
||||
DSN: envOrDefault("DB_dsn", "file:./data/data.db?_foreign_keys=on"),
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
return cfg, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,51 +4,44 @@ import (
|
|||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"server/internal/config"
|
||||
"strings"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"server/internal/models"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var DB *gorm.DB
|
||||
|
||||
// GetDB returns the global *gorm.DB instance. It panics if the database is not initialized.
|
||||
func GetDB() (*gorm.DB, error) {
|
||||
if DB == nil {
|
||||
cfg, err := config.GetConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
DB, err = InitDB(cfg.Db)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to initialize database: %v\n", err)
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return DB, nil
|
||||
type Config struct {
|
||||
Driver string
|
||||
DSN string
|
||||
}
|
||||
|
||||
// Init opens the configured database connection and runs schema migrations.
|
||||
func InitDB(cfg config.DbConfig) (*gorm.DB, error) {
|
||||
func Init(cfg Config) (*gorm.DB, error) {
|
||||
switch cfg.Driver {
|
||||
case "sqlite":
|
||||
if err := ensureSQLiteDir(cfg.DSN); err != nil {
|
||||
return nil, fmt.Errorf("prepare sqlite path: %w", err)
|
||||
}
|
||||
DB, err := gorm.Open(sqlite.Open(cfg.DSN), &gorm.Config{})
|
||||
db, err := gorm.Open(sqlite.Open(cfg.DSN), &gorm.Config{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open sqlite: %w", err)
|
||||
}
|
||||
return DB, nil
|
||||
if err := db.AutoMigrate(&models.User{}, &models.UserDetails{}, &models.UserPreferences{}, &models.Session{}, &models.PasswordResetToken{}); err != nil {
|
||||
return nil, fmt.Errorf("migrate user: %w", err)
|
||||
}
|
||||
return db, nil
|
||||
case "postgres":
|
||||
DB, err := gorm.Open(postgres.Open(cfg.DSN), &gorm.Config{})
|
||||
db, err := gorm.Open(postgres.Open(cfg.DSN), &gorm.Config{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open postgres: %w", err)
|
||||
}
|
||||
return DB, nil
|
||||
if err := db.AutoMigrate(&models.User{}, &models.UserDetails{}, &models.UserPreferences{}, &models.Session{}, &models.PasswordResetToken{}); err != nil {
|
||||
return nil, fmt.Errorf("migrate user: %w", err)
|
||||
}
|
||||
return db, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported driver %q", cfg.Driver)
|
||||
}
|
||||
|
|
@ -69,17 +62,3 @@ 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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
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)
|
||||
}
|
||||
|
|
@ -10,7 +10,6 @@ import (
|
|||
"net/smtp"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"server/internal/config"
|
||||
"strings"
|
||||
texttemplate "text/template"
|
||||
"time"
|
||||
|
|
@ -41,7 +40,7 @@ type Message struct {
|
|||
TemplateData any
|
||||
}
|
||||
|
||||
type MailService struct {
|
||||
type Service struct {
|
||||
cfg Config
|
||||
}
|
||||
|
||||
|
|
@ -53,30 +52,7 @@ type TemplateData struct {
|
|||
ResetToken string
|
||||
}
|
||||
|
||||
// if service fail send admin allert instead a response to user or a simple response server error.
|
||||
func New() (*MailService, error) {
|
||||
|
||||
serverCfg, err := config.GetConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cfg := Config{
|
||||
AppName: serverCfg.AppName,
|
||||
Mode: serverCfg.Mail.Mode,
|
||||
From: serverCfg.Mail.From,
|
||||
DebugDir: serverCfg.Mail.DebugDir,
|
||||
TemplatesDir: serverCfg.Mail.TemplatesDir,
|
||||
FrontendBaseURL: serverCfg.Mail.FrontendBaseURL,
|
||||
ResetPasswordPath: serverCfg.Mail.ResetPasswordPath,
|
||||
SMTP: SMTPConfig{
|
||||
Host: serverCfg.Mail.SMTP.Host,
|
||||
Port: serverCfg.Mail.SMTP.Port,
|
||||
Username: serverCfg.Mail.SMTP.Username,
|
||||
Password: serverCfg.Mail.SMTP.Password,
|
||||
},
|
||||
}
|
||||
|
||||
func New(cfg Config) (*Service, error) {
|
||||
if cfg.Mode != "smtp" && cfg.Mode != "file" {
|
||||
return nil, fmt.Errorf("unsupported mail mode %q", cfg.Mode)
|
||||
}
|
||||
|
|
@ -102,10 +78,10 @@ func New() (*MailService, error) {
|
|||
return nil, fmt.Errorf("smtp host and port are required")
|
||||
}
|
||||
}
|
||||
return &MailService{cfg: cfg}, nil
|
||||
return &Service{cfg: cfg}, nil
|
||||
}
|
||||
|
||||
func (s *MailService) Send(ctx context.Context, msg Message) error {
|
||||
func (s *Service) Send(ctx context.Context, msg Message) error {
|
||||
htmlBody, textBody, err := s.renderBodies(msg.Template, msg.TemplateData)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -122,7 +98,7 @@ func (s *MailService) Send(ctx context.Context, msg Message) error {
|
|||
}
|
||||
}
|
||||
|
||||
func (s *MailService) ResetLink(token string) string {
|
||||
func (s *Service) ResetLink(token string) string {
|
||||
base := strings.TrimRight(s.cfg.FrontendBaseURL, "/")
|
||||
path := s.cfg.ResetPasswordPath
|
||||
if path == "" {
|
||||
|
|
@ -137,11 +113,11 @@ func (s *MailService) ResetLink(token string) string {
|
|||
return base + path + "?token=" + token
|
||||
}
|
||||
|
||||
func (s *MailService) AppName() string {
|
||||
func (s *Service) AppName() string {
|
||||
return s.cfg.AppName
|
||||
}
|
||||
|
||||
func (s *MailService) renderBodies(templateName string, data any) (string, string, error) {
|
||||
func (s *Service) renderBodies(templateName string, data any) (string, string, error) {
|
||||
htmlPath := filepath.Join(s.cfg.TemplatesDir, templateName+".html.tmpl")
|
||||
textPath := filepath.Join(s.cfg.TemplatesDir, templateName+".txt.tmpl")
|
||||
|
||||
|
|
@ -195,7 +171,7 @@ func buildMessage(from, to, subject, textBody, htmlBody string) []byte {
|
|||
return []byte(strings.Join(append(headers, body...), "\r\n"))
|
||||
}
|
||||
|
||||
func (s *MailService) sendSMTP(ctx context.Context, to string, raw []byte) error {
|
||||
func (s *Service) sendSMTP(ctx context.Context, to string, raw []byte) error {
|
||||
addr := fmt.Sprintf("%s:%d", s.cfg.SMTP.Host, s.cfg.SMTP.Port)
|
||||
dialer := &net.Dialer{}
|
||||
conn, err := dialer.DialContext(ctx, "tcp", addr)
|
||||
|
|
@ -245,7 +221,7 @@ func (s *MailService) sendSMTP(ctx context.Context, to string, raw []byte) error
|
|||
return nil
|
||||
}
|
||||
|
||||
func (s *MailService) writeDebugMail(to, subject string, raw []byte) error {
|
||||
func (s *Service) writeDebugMail(to, subject string, raw []byte) error {
|
||||
safeRecipient := strings.NewReplacer("@", "_at_", "/", "_", "\\", "_", ":", "_", " ", "_").Replace(to)
|
||||
filename := fmt.Sprintf("%d_%s.eml", time.Now().UnixNano(), safeRecipient)
|
||||
path := filepath.Join(s.cfg.DebugDir, filename)
|
||||
|
|
|
|||
|
|
@ -1,15 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package users
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
|
@ -11,6 +11,9 @@ 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"`
|
||||
|
|
@ -40,13 +43,44 @@ type UserCreateInput struct {
|
|||
Status UserStatus `json:"status"`
|
||||
Types UserTypes `json:"types"`
|
||||
Avatar *string `json:"avatar"`
|
||||
Details *UserDetails `json:"details" `
|
||||
Preferences *UserPreferences `json:"preferences" `
|
||||
Details *UserDetailsShort `json:"details" `
|
||||
Preferences *UserPreferencesShort `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
|
||||
|
|
@ -88,6 +122,40 @@ 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"`
|
||||
|
|
@ -106,6 +174,18 @@ 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"`
|
||||
|
|
@ -124,6 +204,18 @@ 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 {
|
||||
|
|
@ -160,25 +252,3 @@ 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"`
|
||||
}
|
||||
|
|
@ -8,9 +8,13 @@ type SimpleResponse struct {
|
|||
}
|
||||
|
||||
// success wraps a payload in the standard API envelope.
|
||||
func Success(data any) fiber.Map {
|
||||
func success(data any) fiber.Map {
|
||||
return fiber.Map{
|
||||
"data": data,
|
||||
"error": nil,
|
||||
}
|
||||
}
|
||||
|
||||
func Success(data any) fiber.Map {
|
||||
return success(data)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,14 +2,28 @@ package routes
|
|||
|
||||
import (
|
||||
"server/internal/admin"
|
||||
"server/internal/auth"
|
||||
"server/internal/mail"
|
||||
"server/internal/systemUtils"
|
||||
users "server/internal/user"
|
||||
"server/internal/user"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
func Register(app *fiber.App) {
|
||||
// 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)
|
||||
users.RegisterUserRoutes(app)
|
||||
auth.Register(app, authService, mailService)
|
||||
user.RegisterUserRoutes(app, authService)
|
||||
admin.RegisterAdminRoutes(app)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,14 +4,13 @@ import (
|
|||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
|
||||
"time"
|
||||
|
||||
"github.com/brianvoe/gofakeit/v6"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"server/internal/systemUtils"
|
||||
users "server/internal/user"
|
||||
"server/internal/auth"
|
||||
"server/internal/models"
|
||||
)
|
||||
|
||||
// Credential exposes the plaintext password generated for a seeded user.
|
||||
|
|
@ -21,14 +20,14 @@ type Credential struct {
|
|||
}
|
||||
|
||||
// SeedUsers generates n fake users and persists them. Returns the created slice.
|
||||
func SeedUsers(db *gorm.DB, n int) ([]users.User, []Credential, error) {
|
||||
func SeedUsers(db *gorm.DB, n int) ([]models.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([]users.User, 0, n)
|
||||
items := make([]models.User, 0, n)
|
||||
creds := make([]Credential, 0, n)
|
||||
for i := 0; i < n; i++ {
|
||||
now := time.Now().UTC()
|
||||
|
|
@ -39,20 +38,20 @@ func SeedUsers(db *gorm.DB, n int) ([]users.User, []Credential, error) {
|
|||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("generate password: %w", err)
|
||||
}
|
||||
passwordHash, err := systemUtils.HashPassword(pw)
|
||||
passwordHash, err := auth.HashPassword(pw)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("hash seed password: %w", err)
|
||||
}
|
||||
|
||||
item := users.User{
|
||||
item := models.User{
|
||||
Email: email,
|
||||
Name: gofakeit.Name(),
|
||||
Password: passwordHash,
|
||||
Roles: users.UserRoles{"user"},
|
||||
Status: users.UserStatusActive,
|
||||
Types: users.UserTypes{"internal"},
|
||||
Roles: models.UserRoles{"user"},
|
||||
Status: models.UserStatusActive,
|
||||
Types: models.UserTypes{"internal"},
|
||||
UUID: uuid,
|
||||
Details: &users.UserDetails{
|
||||
Details: &models.UserDetails{
|
||||
Title: gofakeit.JobTitle(),
|
||||
FirstName: gofakeit.FirstName(),
|
||||
LastName: gofakeit.LastName(),
|
||||
|
|
@ -62,7 +61,7 @@ func SeedUsers(db *gorm.DB, n int) ([]users.User, []Credential, error) {
|
|||
ZipCode: gofakeit.Zip(),
|
||||
Country: gofakeit.Country(),
|
||||
},
|
||||
Preferences: &users.UserPreferences{
|
||||
Preferences: &models.UserPreferences{
|
||||
UseIdle: gofakeit.Bool(),
|
||||
IdleTimeout: gofakeit.Number(1, 30),
|
||||
UseIdlePassword: gofakeit.Bool(),
|
||||
|
|
|
|||
|
|
@ -1,21 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,78 +1,10 @@
|
|||
package tokens
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"server/internal/config"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
type TockenService struct {
|
||||
cfg config.AuthConfig
|
||||
secret []byte
|
||||
accessExpiry time.Duration
|
||||
refreshExpiry time.Duration
|
||||
}
|
||||
|
||||
type Claims struct {
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
TokenType string `json:"type"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// Typescript: interface
|
||||
type TokenPair struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
|
||||
const (
|
||||
TokenTypeAccess = "access"
|
||||
TokenTypeRefresh = "refresh"
|
||||
)
|
||||
|
||||
var Tockens *TockenService
|
||||
|
||||
func GetTockenService() (*TockenService, error) {
|
||||
if Tockens == nil {
|
||||
cfg, err := config.GetConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
Tockens, err = NewTockenService(cfg.Auth)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return Tockens, nil
|
||||
}
|
||||
|
||||
func NewTockenService(cfg config.AuthConfig) (*TockenService, error) {
|
||||
if cfg.Secret == "" {
|
||||
return nil, errors.New("jwt secret is required")
|
||||
}
|
||||
if cfg.AccessTokenExpiryMinutes <= 0 {
|
||||
return nil, errors.New("access token expiry must be positive")
|
||||
}
|
||||
if cfg.RefreshTokenExpiryMinutes <= 0 {
|
||||
return nil, errors.New("refresh token expiry must be positive")
|
||||
}
|
||||
|
||||
return &TockenService{
|
||||
cfg: cfg,
|
||||
secret: []byte(cfg.Secret),
|
||||
accessExpiry: time.Duration(cfg.AccessTokenExpiryMinutes) * time.Minute,
|
||||
refreshExpiry: time.Duration(cfg.RefreshTokenExpiryMinutes) * time.Minute,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func hashToken(token string) string {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(sum[:])
|
||||
|
|
@ -81,131 +13,3 @@ 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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import (
|
|||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
|
||||
"server/internal/config"
|
||||
tsrpc "server/pkg/ts-rpc"
|
||||
)
|
||||
|
||||
|
|
@ -30,6 +32,14 @@ func TsGenerate() (string, error) {
|
|||
return "", fmt.Errorf("write local generated typescript: %w", err)
|
||||
}
|
||||
|
||||
configPath := os.Getenv("CONFIG_PATH")
|
||||
if configPath == "" {
|
||||
configPath = "configs/config.json"
|
||||
}
|
||||
if _, err := config.LoadConfig(configPath); err != nil {
|
||||
return "", fmt.Errorf("load config from %s: %w", configPath, err)
|
||||
}
|
||||
|
||||
frontendAPIPath := os.Getenv("FRONTEND_API_PATH")
|
||||
if frontendAPIPath == "" {
|
||||
return "", errors.New("FRONTEND_API_PATH must be set")
|
||||
|
|
|
|||
|
|
@ -1,34 +1,25 @@
|
|||
package users
|
||||
package user
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"server/internal/db"
|
||||
"server/internal/mail"
|
||||
"server/internal/auth"
|
||||
"server/internal/helpers"
|
||||
"server/internal/models"
|
||||
"server/internal/responses"
|
||||
"server/internal/systemUtils"
|
||||
"server/internal/tokens"
|
||||
|
||||
"server/internal/validation"
|
||||
)
|
||||
|
||||
type UserController struct {
|
||||
TockenService *tokens.TockenService
|
||||
}
|
||||
type UserController struct{}
|
||||
|
||||
func NewUserController(tockenService *tokens.TockenService) *UserController {
|
||||
return &UserController{
|
||||
TockenService: tockenService,
|
||||
}
|
||||
func NewUserController() *UserController {
|
||||
return &UserController{}
|
||||
}
|
||||
|
||||
// Typescript: interface
|
||||
|
|
@ -36,12 +27,12 @@ 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 UserRoles `json:"roles"`
|
||||
Status UserStatus `json:"status"`
|
||||
Types UserTypes `json:"types"`
|
||||
Roles models.UserRoles `json:"roles"`
|
||||
Status models.UserStatus `json:"status"`
|
||||
Types models.UserTypes `json:"types"`
|
||||
Avatar *string `json:"avatar"`
|
||||
Details *UserDetails `json:"details"`
|
||||
Preferences *UserPreferences `json:"preferences"`
|
||||
Details *models.UserDetailsShort `json:"details"`
|
||||
Preferences *models.UserPreferencesShort `json:"preferences"`
|
||||
}
|
||||
|
||||
// GetUser returns a single user by UUID.
|
||||
|
|
@ -50,12 +41,12 @@ func (uc *UserController) GetUser(c fiber.Ctx) error {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.JSON(responses.Success(ToUserProfile(user)))
|
||||
return c.JSON(responses.Success(models.ToUserProfile(user)))
|
||||
}
|
||||
|
||||
// CreateUser creates a user together with optional details and preferences.
|
||||
func (uc *UserController) CreateUser(c fiber.Ctx) error {
|
||||
var req UserCreateInput
|
||||
var req models.UserCreateInput
|
||||
if err := c.Bind().Body(&req); err != nil {
|
||||
return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
|
||||
}
|
||||
|
|
@ -63,50 +54,50 @@ func (uc *UserController) CreateUser(c fiber.Ctx) error {
|
|||
return err
|
||||
}
|
||||
|
||||
db, err := db.DBFromCtx(c)
|
||||
db, err := helpers.DBFromCtx(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var existing User
|
||||
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")
|
||||
}
|
||||
|
||||
hashedPassword, err := systemUtils.HashPassword(req.Password)
|
||||
hashedPassword, err := auth.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "failed to secure password")
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
user := User{
|
||||
user := models.User{
|
||||
Email: req.Email,
|
||||
Name: req.Name,
|
||||
Password: hashedPassword,
|
||||
Roles: func() UserRoles {
|
||||
Roles: func() models.UserRoles {
|
||||
if len(req.Roles) == 0 {
|
||||
return UserRoles{"user"}
|
||||
return models.UserRoles{"user"}
|
||||
}
|
||||
return req.Roles
|
||||
}(),
|
||||
Status: func() UserStatus {
|
||||
Status: func() models.UserStatus {
|
||||
if req.Status == "" {
|
||||
return UserStatusPending
|
||||
return models.UserStatusPending
|
||||
}
|
||||
return req.Status
|
||||
}(),
|
||||
Types: func() UserTypes {
|
||||
Types: func() models.UserTypes {
|
||||
if len(req.Types) == 0 {
|
||||
return UserTypes{"internal"}
|
||||
return models.UserTypes{"internal"}
|
||||
}
|
||||
return req.Types
|
||||
}(),
|
||||
Avatar: req.Avatar,
|
||||
UUID: uuid.NewString(),
|
||||
Details: req.Details,
|
||||
Preferences: req.Preferences,
|
||||
Details: helpers.ToUserDetails(req.Details),
|
||||
Preferences: helpers.ToUserPreferences(req.Preferences),
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
|
@ -119,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(responses.Success(ToUserProfile(&user)))
|
||||
return c.Status(fiber.StatusCreated).JSON(responses.Success(models.ToUserProfile(&user)))
|
||||
}
|
||||
|
||||
// UpdateUser replaces user fields and synchronizes details/preferences.
|
||||
|
|
@ -132,7 +123,7 @@ func (uc *UserController) UpdateUser(c fiber.Ctx) error {
|
|||
return err
|
||||
}
|
||||
|
||||
db, err := db.DBFromCtx(c)
|
||||
db, err := helpers.DBFromCtx(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -143,7 +134,7 @@ func (uc *UserController) UpdateUser(c fiber.Ctx) error {
|
|||
}
|
||||
|
||||
if req.Email != user.Email {
|
||||
var existing User
|
||||
var existing models.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) {
|
||||
|
|
@ -185,12 +176,12 @@ func (uc *UserController) UpdateUser(c fiber.Ctx) error {
|
|||
return fiber.NewError(fiber.StatusInternalServerError, "failed to reload user")
|
||||
}
|
||||
|
||||
return c.JSON(responses.Success(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 := db.DBFromCtx(c)
|
||||
db, err := helpers.DBFromCtx(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -201,10 +192,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(&UserDetails{}).Error; err != nil {
|
||||
if err := tx.Where("user_id = ?", user.ID).Delete(&models.UserDetails{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("user_id = ?", user.ID).Delete(&UserPreferences{}).Error; err != nil {
|
||||
if err := tx.Where("user_id = ?", user.ID).Delete(&models.UserPreferences{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Delete(user).Error
|
||||
|
|
@ -215,348 +206,18 @@ func (uc *UserController) DeleteUser(c fiber.Ctx) error {
|
|||
return c.JSON(responses.Success(responses.SimpleResponse{Message: "user deleted"}))
|
||||
}
|
||||
|
||||
// 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) {
|
||||
func loadUserByID(c fiber.Ctx) (*models.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 := db.DBFromCtx(c)
|
||||
db, err := helpers.DBFromCtx(c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var user User
|
||||
var user models.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")
|
||||
|
|
@ -567,18 +228,18 @@ func loadUserByID(c fiber.Ctx) (*User, error) {
|
|||
return &user, nil
|
||||
}
|
||||
|
||||
func loadUserByUUID(c fiber.Ctx) (*User, error) {
|
||||
func loadUserByUUID(c fiber.Ctx) (*models.User, error) {
|
||||
uuid := c.Params("uuid")
|
||||
if uuid == "" {
|
||||
return nil, fiber.NewError(fiber.StatusBadRequest, "invalid user uuid")
|
||||
}
|
||||
|
||||
db, err := db.DBFromCtx(c)
|
||||
db, err := helpers.DBFromCtx(c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var user User
|
||||
var user models.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")
|
||||
|
|
@ -589,15 +250,15 @@ func loadUserByUUID(c fiber.Ctx) (*User, error) {
|
|||
return &user, nil
|
||||
}
|
||||
|
||||
func syncUserDetails(tx *gorm.DB, userID int, input *UserDetails) error {
|
||||
func syncUserDetails(tx *gorm.DB, userID int, input *models.UserDetailsShort) error {
|
||||
if input == nil {
|
||||
return tx.Where("user_id = ?", userID).Delete(&UserDetails{}).Error
|
||||
return tx.Where("user_id = ?", userID).Delete(&models.UserDetails{}).Error
|
||||
}
|
||||
|
||||
var details UserDetails
|
||||
var details models.UserDetails
|
||||
if err := tx.Where("user_id = ?", userID).First(&details).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
details = UserDetails{UserID: userID}
|
||||
details = models.UserDetails{UserID: userID}
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
|
|
@ -618,15 +279,15 @@ func syncUserDetails(tx *gorm.DB, userID int, input *UserDetails) error {
|
|||
return tx.Save(&details).Error
|
||||
}
|
||||
|
||||
func syncUserPreferences(tx *gorm.DB, userID int, input *UserPreferences) error {
|
||||
func syncUserPreferences(tx *gorm.DB, userID int, input *models.UserPreferencesShort) error {
|
||||
if input == nil {
|
||||
return tx.Where("user_id = ?", userID).Delete(&UserPreferences{}).Error
|
||||
return tx.Where("user_id = ?", userID).Delete(&models.UserPreferences{}).Error
|
||||
}
|
||||
|
||||
var preferences UserPreferences
|
||||
var preferences models.UserPreferences
|
||||
if err := tx.Where("user_id = ?", userID).First(&preferences).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
preferences = UserPreferences{UserID: userID}
|
||||
preferences = models.UserPreferences{UserID: userID}
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
|
|
@ -649,17 +310,17 @@ func syncUserPreferences(tx *gorm.DB, userID int, input *UserPreferences) error
|
|||
|
||||
// Me returns the authenticated user's profile (short format).
|
||||
func (uc *UserController) Me(c fiber.Ctx) error {
|
||||
claims, ok := systemUtils.ClaimsFromCtx(c)
|
||||
claims, ok := auth.ClaimsFromCtx(c)
|
||||
if !ok {
|
||||
return fiber.NewError(fiber.StatusUnauthorized, "missing claims")
|
||||
}
|
||||
|
||||
db, err := db.DBFromCtx(c)
|
||||
db, err := helpers.DBFromCtx(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var user User
|
||||
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")
|
||||
|
|
@ -667,28 +328,5 @@ func (uc *UserController) Me(c fiber.Ctx) error {
|
|||
return fiber.NewError(fiber.StatusInternalServerError, "failed to load user")
|
||||
}
|
||||
|
||||
return c.JSON(responses.Success(&user))
|
||||
}
|
||||
|
||||
func (us *UserController) Refresh(c fiber.Ctx) error {
|
||||
var req RefreshRequest
|
||||
if err := c.Bind().Body(&req); err != nil {
|
||||
return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
|
||||
}
|
||||
if req.RefreshToken == "" {
|
||||
return fiber.NewError(fiber.StatusBadRequest, "refresh_token is required")
|
||||
}
|
||||
|
||||
claims, err := us.TockenService.ParseToken(req.RefreshToken)
|
||||
if err != nil {
|
||||
return fiber.NewError(fiber.StatusUnauthorized, err.Error())
|
||||
}
|
||||
if claims.TokenType != tokens.TokenTypeRefresh {
|
||||
return fiber.NewError(fiber.StatusUnauthorized, "refresh token required")
|
||||
}
|
||||
tokens, err := us.TockenService.GenerateTokenPair(claims.Username)
|
||||
if err != nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
return c.JSON(responses.Success(tokens))
|
||||
return c.JSON(responses.Success(models.ToUserShort(&user)))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,60 +1,28 @@
|
|||
package users
|
||||
package user
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"server/internal/roles"
|
||||
"server/internal/tokens"
|
||||
"time"
|
||||
"server/internal/auth"
|
||||
"server/internal/authorization"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"github.com/gofiber/fiber/v3/middleware/limiter"
|
||||
)
|
||||
|
||||
func RegisterUserRoutes(app *fiber.App) {
|
||||
tockenService, err := tokens.GetTockenService()
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("token service: %v", err))
|
||||
}
|
||||
func RegisterUserRoutes(app *fiber.App, authService *auth.AuthService) {
|
||||
userController := NewUserController()
|
||||
|
||||
authRateLimiter := limiter.New(limiter.Config{
|
||||
Max: 10,
|
||||
Expiration: time.Minute,
|
||||
LimiterMiddleware: limiter.SlidingWindow{},
|
||||
})
|
||||
// Typescript: TSEndpoint= path=/users/:uuid; name=getUser; method=GET; response=models.UserProfile
|
||||
app.Get("/users/:uuid", authService.Middleware(), userController.GetUser)
|
||||
|
||||
userController := NewUserController(tockenService)
|
||||
// Typescript: TSEndpoint= path=/users; name=createUser; method=POST; request=models.UserCreateInput; response=models.UserProfile
|
||||
app.Post("/users", authService.Middleware(), userController.CreateUser)
|
||||
|
||||
// Typescript: TSEndpoint= path=/users/:uuid; name=getUser; method=GET; response=users.UserProfile
|
||||
app.Get("/users/:uuid", tockenService.Middleware(), userController.GetUser)
|
||||
// Typescript: TSEndpoint= path=/users/:uuid; name=updateUser; method=PUT; request=controllers.UpdateUserRequest; response=models.UserProfile
|
||||
app.Put("/users/:uuid", authService.Middleware(), userController.UpdateUser)
|
||||
|
||||
// Typescript: TSEndpoint= path=/users; name=createUser; method=POST; request=users.UserCreateInput; response=users.UserProfile
|
||||
app.Post("/users", tockenService.Middleware(), userController.CreateUser)
|
||||
// Typescript: TSEndpoint= path=/users/:uuid; name=deleteUser; method=DELETE; response=controllers.SimpleResponse
|
||||
app.Delete("/users/:uuid", authService.Middleware(), userController.DeleteUser)
|
||||
|
||||
// Typescript: TSEndpoint= path=/users/:uuid; name=updateUser; method=PUT; request=users.UpdateUserRequest; response=users.UserProfile
|
||||
app.Put("/users/:uuid", tockenService.Middleware(), userController.UpdateUser)
|
||||
|
||||
// Typescript: TSEndpoint= path=/users/:uuid; name=deleteUser; method=DELETE; response=responses.SimpleResponse
|
||||
app.Delete("/users/:uuid", tockenService.Middleware(), userController.DeleteUser)
|
||||
|
||||
// Typescript: TSEndpoint= path=/auth/me; name=me; method=GET; response=users.User
|
||||
app.Get("/auth/me", tockenService.Middleware(), userController.Me)
|
||||
roles.RegisterEndpoint("GET/auth/me", int(roles.UserPermission))
|
||||
|
||||
// Typescript: TSEndpoint= path=/auth/login; name=login; method=POST; request=users.LoginRequest; response=tokens.TokenPair
|
||||
app.Post("/auth/login", authRateLimiter, userController.Login)
|
||||
|
||||
// Typescript: TSEndpoint= path=/auth/refresh; name=refresh; method=POST; request=tokens.RefreshRequest; response=tokens.TokenPair
|
||||
app.Post("/auth/refresh", authRateLimiter, userController.Refresh)
|
||||
|
||||
// Typescript: TSEndpoint= path=/auth/register; name=register; method=POST; request=users.UserCreateInput; response=users.User
|
||||
app.Post("/auth/register", authRateLimiter, userController.Register)
|
||||
|
||||
// Typescript: TSEndpoint= path=/auth/password/forgot; name=forgotPassword; method=POST; request=users.ForgotPasswordRequest; response=responses.SimpleResponse
|
||||
app.Post("/auth/password/forgot", authRateLimiter, userController.ForgotPassword)
|
||||
|
||||
// Typescript: TSEndpoint= path=/auth/password/reset; name=resetPassword; method=POST; request=users.ResetPasswordRequest; response=responses.SimpleResponse
|
||||
app.Post("/auth/password/reset", authRateLimiter, userController.ResetPassword)
|
||||
|
||||
// Typescript: TSEndpoint= path=/auth/password/valid; name=validToken; method=POST; request=string; response=responses.SimpleResponse
|
||||
app.Post("/auth/password/valid", authRateLimiter, userController.ValidToken)
|
||||
// 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))
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue