Compare commits
2 Commits
b3741f86c8
...
5b9fe6c9b7
| Author | SHA1 | Date |
|---|---|---|
|
|
5b9fe6c9b7 | |
|
|
3731e6e409 |
|
|
@ -4,7 +4,7 @@
|
||||||
//
|
//
|
||||||
// This file was generated by github.com/millevolte/ts-rpc
|
// This file was generated by github.com/millevolte/ts-rpc
|
||||||
//
|
//
|
||||||
// Apr 05, 2026 20:12:24 UTC
|
// Apr 06, 2026 16:56:35 UTC
|
||||||
//
|
//
|
||||||
|
|
||||||
export interface ApiRestResponse {
|
export interface ApiRestResponse {
|
||||||
|
|
@ -280,10 +280,205 @@ export type Nullable<T> = T | null;
|
||||||
|
|
||||||
export type Record<K extends string | number | symbol, T> = { [P in K]: T };
|
export type Record<K extends string | number | symbol, T> = { [P in K]: T };
|
||||||
|
|
||||||
|
//
|
||||||
|
// package user
|
||||||
|
//
|
||||||
|
|
||||||
|
// Typescript: TSEndpoint= path=/users/:uuid; name=getUser; method=GET; response=models.UserProfile
|
||||||
|
// internal/user/routes.go Line: 13
|
||||||
|
export const getUser = async (
|
||||||
|
uuid: string,
|
||||||
|
): Promise<{ data: UserProfile; error: Nullable<string> }> => {
|
||||||
|
return (await api.GET(`/users/${uuid}`)) as {
|
||||||
|
data: UserProfile;
|
||||||
|
error: Nullable<string>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Typescript: TSEndpoint= path=/users; name=createUser; method=POST; request=models.UserCreateInput; response=models.UserProfile
|
||||||
|
// internal/user/routes.go Line: 16
|
||||||
|
|
||||||
|
export const createUser = async (
|
||||||
|
data: UserCreateInput,
|
||||||
|
): Promise<{ data: UserProfile; error: Nullable<string> }> => {
|
||||||
|
return (await api.POST("/users", data)) as {
|
||||||
|
data: UserProfile;
|
||||||
|
error: Nullable<string>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Typescript: TSEndpoint= path=/users/:uuid; name=updateUser; method=PUT; request=controllers.UpdateUserRequest; response=models.UserProfile
|
||||||
|
// internal/user/routes.go Line: 19
|
||||||
|
|
||||||
|
export const updateUser = async (
|
||||||
|
data: UpdateUserRequest,
|
||||||
|
): Promise<{ data: UserProfile; error: Nullable<string> }> => {
|
||||||
|
return (await api.PUT("/users/:uuid", data)) as {
|
||||||
|
data: UserProfile;
|
||||||
|
error: Nullable<string>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Typescript: TSEndpoint= path=/users/:uuid; name=deleteUser; method=DELETE; response=controllers.SimpleResponse
|
||||||
|
// internal/user/routes.go Line: 22
|
||||||
|
|
||||||
|
export const deleteUser = async (
|
||||||
|
uuid: string,
|
||||||
|
): Promise<{ data: SimpleResponse; error: Nullable<string> }> => {
|
||||||
|
return (await api.DELETE(`/users/${uuid}`)) as {
|
||||||
|
data: SimpleResponse;
|
||||||
|
error: Nullable<string>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Typescript: TSEndpoint= path=/auth/me; name=me; method=GET; response=models.UserShort
|
||||||
|
// internal/user/routes.go Line: 25
|
||||||
|
export const me = async (): Promise<{
|
||||||
|
data: UserShort;
|
||||||
|
error: Nullable<string>;
|
||||||
|
}> => {
|
||||||
|
return (await api.GET("/auth/me")) as {
|
||||||
|
data: UserShort;
|
||||||
|
error: Nullable<string>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface UpdateUserRequest {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
roles: models.UserRoles;
|
||||||
|
status: models.UserStatus;
|
||||||
|
types: models.UserTypes;
|
||||||
|
avatar: Nullable<string>;
|
||||||
|
details: Nullable<models.UserDetailsShort>;
|
||||||
|
preferences: Nullable<models.UserPreferencesShort>;
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// package models
|
||||||
|
//
|
||||||
|
|
||||||
|
export interface UserDetailsShort {
|
||||||
|
title: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
address: string;
|
||||||
|
city: string;
|
||||||
|
zipCode: string;
|
||||||
|
country: string;
|
||||||
|
phone: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserPreferencesShort {
|
||||||
|
useIdle: boolean;
|
||||||
|
idleTimeout: number;
|
||||||
|
useIdlePassword: boolean;
|
||||||
|
idlePin: string;
|
||||||
|
useDirectLogin: boolean;
|
||||||
|
useQuadcodeLogin: boolean;
|
||||||
|
sendNoticesMail: boolean;
|
||||||
|
language: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserShort {
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
roles: UserRoles;
|
||||||
|
status: UserStatus;
|
||||||
|
uuid: string;
|
||||||
|
details: Nullable<UserDetailsShort>;
|
||||||
|
preferences: Nullable<UserPreferencesShort>;
|
||||||
|
avatar: Nullable<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserCreateInput {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
roles: UserRoles;
|
||||||
|
status: UserStatus;
|
||||||
|
types: UserTypes;
|
||||||
|
avatar: Nullable<string>;
|
||||||
|
details: Nullable<UserDetailsShort>;
|
||||||
|
preferences: Nullable<UserPreferencesShort>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UserRoles = string[];
|
||||||
|
|
||||||
|
export type UserStatus = (typeof EnumUserStatus)[keyof typeof EnumUserStatus];
|
||||||
|
|
||||||
|
export type UserTypes = string[];
|
||||||
|
|
||||||
|
export type UsersShort = UserShort[];
|
||||||
|
|
||||||
|
export const EnumUserStatus = {
|
||||||
|
UserStatusPending: "pending",
|
||||||
|
UserStatusActive: "active",
|
||||||
|
UserStatusDisabled: "disabled",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
//
|
||||||
|
// package admin
|
||||||
|
//
|
||||||
|
|
||||||
|
// Typescript: TSEndpoint= path=/admin/users/:uuid/block; name=blockUser; method=PUT; request=admin.BlockUserRequest; response=models.UserShort
|
||||||
|
// internal/admin/routes.go Line: 16
|
||||||
|
|
||||||
|
export const blockUser = async (
|
||||||
|
data: BlockUserRequest,
|
||||||
|
): Promise<{ data: UserShort; error: Nullable<string> }> => {
|
||||||
|
return (await api.PUT("/admin/users/:uuid/block", data)) as {
|
||||||
|
data: UserShort;
|
||||||
|
error: Nullable<string>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Typescript: TSEndpoint= path=/admin/users; name=listUsers; method=POST; request=admin.ListUsersRequest; response=models.[]UserShort
|
||||||
|
// internal/admin/routes.go Line: 12
|
||||||
|
|
||||||
|
export const listUsers = async (
|
||||||
|
data: ListUsersRequest,
|
||||||
|
): Promise<{ data: UserShort[]; error: Nullable<string> }> => {
|
||||||
|
return (await api.POST("/admin/users", data)) as {
|
||||||
|
data: UserShort[];
|
||||||
|
error: Nullable<string>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface BlockUserRequest {
|
||||||
|
action: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListUsersRequest {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// package responses
|
||||||
|
//
|
||||||
|
|
||||||
|
export interface SimpleResponse {
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
// package systemUtils
|
// package systemUtils
|
||||||
//
|
//
|
||||||
|
|
||||||
|
// Typescript: TSEndpoint= path=/health; name=health; method=GET; response=string
|
||||||
|
// internal/systemUtils/routes.go Line: 34
|
||||||
|
export const health = async (): Promise<{
|
||||||
|
data: string;
|
||||||
|
error: Nullable<string>;
|
||||||
|
}> => {
|
||||||
|
return (await api.GET("/health")) as {
|
||||||
|
data: string;
|
||||||
|
error: Nullable<string>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
// Typescript: TSEndpoint= path=/metrics; name=metrics; method=GET; response=string
|
// Typescript: TSEndpoint= path=/metrics; name=metrics; method=GET; response=string
|
||||||
// internal/systemUtils/routes.go Line: 37
|
// internal/systemUtils/routes.go Line: 37
|
||||||
export const metrics = async (): Promise<{
|
export const metrics = async (): Promise<{
|
||||||
|
|
@ -308,114 +503,30 @@ export const mailDebug = async (): Promise<{
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// Typescript: TSEndpoint= path=/health; name=health; method=GET; response=string
|
|
||||||
// internal/systemUtils/routes.go Line: 34
|
|
||||||
export const health = async (): Promise<{
|
|
||||||
data: string;
|
|
||||||
error: Nullable<string>;
|
|
||||||
}> => {
|
|
||||||
return (await api.GET("/health")) as {
|
|
||||||
data: string;
|
|
||||||
error: Nullable<string>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface MailDebugItem {
|
export interface MailDebugItem {
|
||||||
name: string;
|
name: string;
|
||||||
content: string;
|
content: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
// package admin
|
// package routes
|
||||||
//
|
//
|
||||||
|
|
||||||
// Typescript: TSEndpoint= path=/admin/users; name=listUsers; method=POST; request=admin.ListUsersRequest; response=models.[]UserShort
|
export interface FormRequest {
|
||||||
// internal/admin/routes.go Line: 12
|
req: string;
|
||||||
|
count: number;
|
||||||
export const listUsers = async (
|
|
||||||
data: ListUsersRequest,
|
|
||||||
): Promise<{ data: UserShort[]; error: Nullable<string> }> => {
|
|
||||||
return (await api.POST("/admin/users", data)) as {
|
|
||||||
data: UserShort[];
|
|
||||||
error: Nullable<string>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Typescript: TSEndpoint= path=/admin/users/:uuid/block; name=blockUser; method=PUT; request=admin.BlockUserRequest; response=models.UserShort
|
|
||||||
// internal/admin/routes.go Line: 16
|
|
||||||
|
|
||||||
export const blockUser = async (
|
|
||||||
data: BlockUserRequest,
|
|
||||||
): Promise<{ data: UserShort; error: Nullable<string> }> => {
|
|
||||||
return (await api.PUT("/admin/users/:uuid/block", data)) as {
|
|
||||||
data: UserShort;
|
|
||||||
error: Nullable<string>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface BlockUserRequest {
|
|
||||||
action: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ListUsersRequest {
|
export interface FormResponse {
|
||||||
page: number;
|
test: string;
|
||||||
pageSize: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
// package auth
|
// package auth
|
||||||
//
|
//
|
||||||
|
|
||||||
// Typescript: TSEndpoint= path=/auth/refresh; name=refresh; method=POST; request=model.RefreshRequest; response=model.TokenPair
|
|
||||||
// internal/auth/routes.go Line: 23
|
|
||||||
|
|
||||||
export const refresh = async (
|
|
||||||
data: RefreshRequest,
|
|
||||||
): Promise<{ data: TokenPair; error: Nullable<string> }> => {
|
|
||||||
return (await api.POST("/auth/refresh", data)) as {
|
|
||||||
data: TokenPair;
|
|
||||||
error: Nullable<string>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Typescript: TSEndpoint= path=/auth/me; name=me; method=GET; response=models.UserShort
|
|
||||||
// internal/auth/routes.go Line: 26
|
|
||||||
export const me = async (): Promise<{
|
|
||||||
data: UserShort;
|
|
||||||
error: Nullable<string>;
|
|
||||||
}> => {
|
|
||||||
return (await api.GET("/auth/me")) as {
|
|
||||||
data: UserShort;
|
|
||||||
error: Nullable<string>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Typescript: TSEndpoint= path=/auth/register; name=register; method=POST; request=models.UserCreateInput; response=models.UserShort
|
|
||||||
// internal/auth/routes.go Line: 29
|
|
||||||
|
|
||||||
export const register = async (
|
|
||||||
data: UserCreateInput,
|
|
||||||
): Promise<{ data: UserShort; error: Nullable<string> }> => {
|
|
||||||
return (await api.POST("/auth/register", data)) as {
|
|
||||||
data: UserShort;
|
|
||||||
error: Nullable<string>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Typescript: TSEndpoint= path=/auth/password/forgot; name=forgotPassword; method=POST; request=model.ForgotPasswordRequest; response=controllers.SimpleResponse
|
|
||||||
// internal/auth/routes.go Line: 32
|
|
||||||
|
|
||||||
export const forgotPassword = async (
|
|
||||||
data: ForgotPasswordRequest,
|
|
||||||
): Promise<{ data: SimpleResponse; error: Nullable<string> }> => {
|
|
||||||
return (await api.POST("/auth/password/forgot", data)) as {
|
|
||||||
data: SimpleResponse;
|
|
||||||
error: Nullable<string>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Typescript: TSEndpoint= path=/auth/password/reset; name=resetPassword; method=POST; request=model.ResetPasswordRequest; response=controllers.SimpleResponse
|
// Typescript: TSEndpoint= path=/auth/password/reset; name=resetPassword; method=POST; request=model.ResetPasswordRequest; response=controllers.SimpleResponse
|
||||||
// internal/auth/routes.go Line: 35
|
// internal/auth/routes.go Line: 32
|
||||||
|
|
||||||
export const resetPassword = async (
|
export const resetPassword = async (
|
||||||
data: ResetPasswordRequest,
|
data: ResetPasswordRequest,
|
||||||
|
|
@ -427,7 +538,7 @@ export const resetPassword = async (
|
||||||
};
|
};
|
||||||
|
|
||||||
// Typescript: TSEndpoint= path=/auth/password/valid; name=validToken; method=POST; request=string; response=controllers.SimpleResponse
|
// Typescript: TSEndpoint= path=/auth/password/valid; name=validToken; method=POST; request=string; response=controllers.SimpleResponse
|
||||||
// internal/auth/routes.go Line: 38
|
// internal/auth/routes.go Line: 35
|
||||||
|
|
||||||
export const validToken = async (
|
export const validToken = async (
|
||||||
data: string,
|
data: string,
|
||||||
|
|
@ -450,172 +561,61 @@ export const login = async (
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface ResetPasswordRequest {
|
// Typescript: TSEndpoint= path=/auth/refresh; name=refresh; method=POST; request=model.RefreshRequest; response=model.TokenPair
|
||||||
token: string;
|
// internal/auth/routes.go Line: 23
|
||||||
password: string;
|
|
||||||
}
|
export const refresh = async (
|
||||||
|
data: RefreshRequest,
|
||||||
|
): Promise<{ data: TokenPair; error: Nullable<string> }> => {
|
||||||
|
return (await api.POST("/auth/refresh", data)) as {
|
||||||
|
data: TokenPair;
|
||||||
|
error: Nullable<string>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Typescript: TSEndpoint= path=/auth/register; name=register; method=POST; request=models.UserCreateInput; response=models.UserShort
|
||||||
|
// internal/auth/routes.go Line: 26
|
||||||
|
|
||||||
|
export const register = async (
|
||||||
|
data: UserCreateInput,
|
||||||
|
): Promise<{ data: UserShort; error: Nullable<string> }> => {
|
||||||
|
return (await api.POST("/auth/register", data)) as {
|
||||||
|
data: UserShort;
|
||||||
|
error: Nullable<string>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Typescript: TSEndpoint= path=/auth/password/forgot; name=forgotPassword; method=POST; request=model.ForgotPasswordRequest; response=controllers.SimpleResponse
|
||||||
|
// internal/auth/routes.go Line: 29
|
||||||
|
|
||||||
|
export const forgotPassword = async (
|
||||||
|
data: ForgotPasswordRequest,
|
||||||
|
): Promise<{ data: SimpleResponse; error: Nullable<string> }> => {
|
||||||
|
return (await api.POST("/auth/password/forgot", data)) as {
|
||||||
|
data: SimpleResponse;
|
||||||
|
error: Nullable<string>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export interface TokenPair {
|
export interface TokenPair {
|
||||||
access_token: string;
|
access_token: string;
|
||||||
refresh_token: string;
|
refresh_token: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ForgotPasswordRequest {
|
|
||||||
email: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LoginRequest {
|
export interface LoginRequest {
|
||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ForgotPasswordRequest {
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RefreshRequest {
|
export interface RefreshRequest {
|
||||||
refresh_token: string;
|
refresh_token: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
export interface ResetPasswordRequest {
|
||||||
// package user
|
token: string;
|
||||||
//
|
|
||||||
|
|
||||||
// Typescript: TSEndpoint= path=/users/:uuid; name=updateUser; method=PUT; request=controllers.UpdateUserRequest; response=models.UserProfile
|
|
||||||
// internal/user/routes.go Line: 18
|
|
||||||
|
|
||||||
export const updateUser = async (
|
|
||||||
data: UpdateUserRequest,
|
|
||||||
): Promise<{ data: UserProfile; error: Nullable<string> }> => {
|
|
||||||
return (await api.PUT("/users/:uuid", data)) as {
|
|
||||||
data: UserProfile;
|
|
||||||
error: Nullable<string>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Typescript: TSEndpoint= path=/users/:uuid; name=deleteUser; method=DELETE; response=controllers.SimpleResponse
|
|
||||||
// internal/user/routes.go Line: 21
|
|
||||||
|
|
||||||
export const deleteUser = async (
|
|
||||||
uuid: string,
|
|
||||||
): Promise<{ data: SimpleResponse; error: Nullable<string> }> => {
|
|
||||||
return (await api.DELETE(`/users/${uuid}`)) as {
|
|
||||||
data: SimpleResponse;
|
|
||||||
error: Nullable<string>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Typescript: TSEndpoint= path=/users/:uuid; name=getUser; method=GET; response=models.UserProfile
|
|
||||||
// internal/user/routes.go Line: 12
|
|
||||||
export const getUser = async (
|
|
||||||
uuid: string,
|
|
||||||
): Promise<{ data: UserProfile; error: Nullable<string> }> => {
|
|
||||||
return (await api.GET(`/users/${uuid}`)) as {
|
|
||||||
data: UserProfile;
|
|
||||||
error: Nullable<string>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Typescript: TSEndpoint= path=/users; name=createUser; method=POST; request=models.UserCreateInput; response=models.UserProfile
|
|
||||||
// internal/user/routes.go Line: 15
|
|
||||||
|
|
||||||
export const createUser = async (
|
|
||||||
data: UserCreateInput,
|
|
||||||
): Promise<{ data: UserProfile; error: Nullable<string> }> => {
|
|
||||||
return (await api.POST("/users", data)) as {
|
|
||||||
data: UserProfile;
|
|
||||||
error: Nullable<string>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface UpdateUserRequest {
|
|
||||||
name: string;
|
|
||||||
email: string;
|
|
||||||
password: string;
|
password: string;
|
||||||
roles: models.UserRoles;
|
|
||||||
status: models.UserStatus;
|
|
||||||
types: models.UserTypes;
|
|
||||||
avatar: Nullable<string>;
|
|
||||||
details: Nullable<models.UserDetailsShort>;
|
|
||||||
preferences: Nullable<models.UserPreferencesShort>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
|
||||||
// package responses
|
|
||||||
//
|
|
||||||
|
|
||||||
export interface SimpleResponse {
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
|
||||||
// package routes
|
|
||||||
//
|
|
||||||
|
|
||||||
export interface FormRequest {
|
|
||||||
req: string;
|
|
||||||
count: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FormResponse {
|
|
||||||
test: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
|
||||||
// package models
|
|
||||||
//
|
|
||||||
|
|
||||||
export interface UserCreateInput {
|
|
||||||
name: string;
|
|
||||||
email: string;
|
|
||||||
password: string;
|
|
||||||
roles: UserRoles;
|
|
||||||
status: UserStatus;
|
|
||||||
types: UserTypes;
|
|
||||||
avatar: Nullable<string>;
|
|
||||||
details: Nullable<UserDetailsShort>;
|
|
||||||
preferences: Nullable<UserPreferencesShort>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UserPreferencesShort {
|
|
||||||
useIdle: boolean;
|
|
||||||
idleTimeout: number;
|
|
||||||
useIdlePassword: boolean;
|
|
||||||
idlePin: string;
|
|
||||||
useDirectLogin: boolean;
|
|
||||||
useQuadcodeLogin: boolean;
|
|
||||||
sendNoticesMail: boolean;
|
|
||||||
language: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UserDetailsShort {
|
|
||||||
title: string;
|
|
||||||
firstName: string;
|
|
||||||
lastName: string;
|
|
||||||
address: string;
|
|
||||||
city: string;
|
|
||||||
zipCode: string;
|
|
||||||
country: string;
|
|
||||||
phone: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UserShort {
|
|
||||||
email: string;
|
|
||||||
name: string;
|
|
||||||
roles: UserRoles;
|
|
||||||
status: UserStatus;
|
|
||||||
uuid: string;
|
|
||||||
details: Nullable<UserDetailsShort>;
|
|
||||||
preferences: Nullable<UserPreferencesShort>;
|
|
||||||
avatar: Nullable<string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type UserTypes = string[];
|
|
||||||
|
|
||||||
export type UsersShort = UserShort[];
|
|
||||||
|
|
||||||
export type UserRoles = string[];
|
|
||||||
|
|
||||||
export type UserStatus = (typeof EnumUserStatus)[keyof typeof EnumUserStatus];
|
|
||||||
|
|
||||||
export const EnumUserStatus = {
|
|
||||||
UserStatusPending: "pending",
|
|
||||||
UserStatusActive: "active",
|
|
||||||
UserStatusDisabled: "disabled",
|
|
||||||
} as const;
|
|
||||||
|
|
|
||||||
|
|
@ -11,13 +11,13 @@ import (
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"server/internal/auth"
|
|
||||||
"server/internal/authorization"
|
|
||||||
"server/internal/config"
|
"server/internal/config"
|
||||||
"server/internal/db"
|
"server/internal/db"
|
||||||
|
"server/internal/migrations"
|
||||||
|
"server/internal/roles"
|
||||||
"server/internal/routes"
|
"server/internal/routes"
|
||||||
|
"server/internal/tokens"
|
||||||
|
|
||||||
"server/internal/mail"
|
|
||||||
"server/internal/seed"
|
"server/internal/seed"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v3"
|
"github.com/gofiber/fiber/v3"
|
||||||
|
|
@ -36,51 +36,23 @@ func main() {
|
||||||
seedCount := flag.Int("seed", 0, "seed N fake users at startup (0 to skip)")
|
seedCount := flag.Int("seed", 0, "seed N fake users at startup (0 to skip)")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
configPath := envOrDefault("CONFIG_PATH", "configs/config.json")
|
cfg, err := config.GetConfig()
|
||||||
cfg, err := config.LoadConfig(configPath)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("load config: %v", err)
|
log.Fatalf("config: %v", err)
|
||||||
}
|
|
||||||
if secret := os.Getenv("AUTH_SECRET"); secret != "" {
|
|
||||||
cfg.Auth.Secret = secret
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dbCfg := db.Config{
|
dbConn, err := db.GetDB()
|
||||||
Driver: envOrDefault("DB_driver", "sqlite"),
|
|
||||||
DSN: envOrDefault("DB_dsn", "file:./data/data.db?_foreign_keys=on"),
|
|
||||||
}
|
|
||||||
dbConn, err := db.Init(dbCfg)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("init db: %v", err)
|
log.Fatalf("init db: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
authService, err := auth.NewAuthService(auth.Config{
|
if err := migrations.AutoMigrate(dbConn); err != nil {
|
||||||
Secret: cfg.Auth.Secret,
|
log.Fatalf("migrate user: %v", err)
|
||||||
Issuer: cfg.Auth.Issuer,
|
|
||||||
AccessTokenExpiry: time.Duration(cfg.Auth.AccessTokenExpiryMinutes) * time.Minute,
|
|
||||||
RefreshTokenExpiry: time.Duration(cfg.Auth.RefreshTokenExpiryMinutes) * time.Minute,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("setup auth: %v", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
mailService, err := mail.New(mail.Config{
|
tokenService, err := tokens.GetTockenService()
|
||||||
AppName: cfg.AppName,
|
|
||||||
Mode: cfg.Mail.Mode,
|
|
||||||
From: cfg.Mail.From,
|
|
||||||
DebugDir: cfg.Mail.DebugDir,
|
|
||||||
TemplatesDir: cfg.Mail.TemplatesDir,
|
|
||||||
FrontendBaseURL: cfg.Mail.FrontendBaseURL,
|
|
||||||
ResetPasswordPath: cfg.Mail.ResetPasswordPath,
|
|
||||||
SMTP: mail.SMTPConfig{
|
|
||||||
Host: cfg.Mail.SMTP.Host,
|
|
||||||
Port: cfg.Mail.SMTP.Port,
|
|
||||||
Username: cfg.Mail.SMTP.Username,
|
|
||||||
Password: cfg.Mail.SMTP.Password,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("setup mail: %v", err)
|
log.Fatalf("init tokens: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
app := fiber.New(fiber.Config{
|
app := fiber.New(fiber.Config{
|
||||||
|
|
@ -125,9 +97,9 @@ func main() {
|
||||||
return c.Next()
|
return c.Next()
|
||||||
})
|
})
|
||||||
|
|
||||||
app.Use(authorization.RequireEndpointPermission(authService, dbConn))
|
app.Use(roles.RequireEndpointPermission(dbConn, tokenService))
|
||||||
|
|
||||||
routes.Register(app, authService, mailService)
|
routes.Register(app)
|
||||||
|
|
||||||
port := envOrDefault("PORT", "3000")
|
port := envOrDefault("PORT", "3000")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
{
|
|
||||||
"roles": {
|
|
||||||
"admin": ["admin", "manager", "user"],
|
|
||||||
"manager": ["manager", "user"],
|
|
||||||
"user": ["user"]
|
|
||||||
},
|
|
||||||
"permissions": {
|
|
||||||
"admin": [
|
|
||||||
"users:read",
|
|
||||||
"users:write",
|
|
||||||
"sessions:purge",
|
|
||||||
"admin:users:list"
|
|
||||||
],
|
|
||||||
"manager": [
|
|
||||||
"users:read"
|
|
||||||
],
|
|
||||||
"user": []
|
|
||||||
},
|
|
||||||
"endpoints": {
|
|
||||||
"POST /admin/users": "admin:users:list"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -7,9 +7,9 @@ import (
|
||||||
"github.com/gofiber/fiber/v3"
|
"github.com/gofiber/fiber/v3"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"server/internal/helpers"
|
"server/internal/db"
|
||||||
"server/internal/models"
|
|
||||||
"server/internal/responses"
|
"server/internal/responses"
|
||||||
|
users "server/internal/user"
|
||||||
"server/internal/validation"
|
"server/internal/validation"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -46,12 +46,12 @@ func (ac *AdminController) ListUsers(c fiber.Ctx) error {
|
||||||
req.PageSize = 20
|
req.PageSize = 20
|
||||||
}
|
}
|
||||||
|
|
||||||
db, err := helpers.DBFromCtx(c)
|
db, err := db.DBFromCtx(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
var list []models.User
|
var list []users.User
|
||||||
offset := (req.Page - 1) * req.PageSize
|
offset := (req.Page - 1) * req.PageSize
|
||||||
if err := db.Preload("Details").Preload("Preferences").
|
if err := db.Preload("Details").Preload("Preferences").
|
||||||
Limit(req.PageSize).
|
Limit(req.PageSize).
|
||||||
|
|
@ -60,17 +60,11 @@ func (ac *AdminController) ListUsers(c fiber.Ctx) error {
|
||||||
return fiber.NewError(fiber.StatusInternalServerError, "failed to load users")
|
return fiber.NewError(fiber.StatusInternalServerError, "failed to load users")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map to short representation
|
|
||||||
short := make([]models.UserShort, 0, len(list))
|
|
||||||
for i := range list {
|
|
||||||
short = append(short, models.ToUserShort(&list[i]))
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.JSON(fiber.Map{
|
return c.JSON(fiber.Map{
|
||||||
"data": fiber.Map{
|
"data": fiber.Map{
|
||||||
"page": req.Page,
|
"page": req.Page,
|
||||||
"pageSize": req.PageSize,
|
"pageSize": req.PageSize,
|
||||||
"items": short,
|
"items": list,
|
||||||
},
|
},
|
||||||
"error": nil,
|
"error": nil,
|
||||||
})
|
})
|
||||||
|
|
@ -86,7 +80,7 @@ func (ac *AdminController) BlockUser(c fiber.Ctx) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
db, err := helpers.DBFromCtx(c)
|
db, err := db.DBFromCtx(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -96,8 +90,8 @@ func (ac *AdminController) BlockUser(c fiber.Ctx) error {
|
||||||
return fiber.NewError(fiber.StatusBadRequest, "invalid user uuid")
|
return fiber.NewError(fiber.StatusBadRequest, "invalid user uuid")
|
||||||
}
|
}
|
||||||
|
|
||||||
var user models.User
|
var u users.User
|
||||||
if err := db.Preload("Details").Preload("Preferences").Where("uuid = ?", uuid).First(&user).Error; err != nil {
|
if err := db.Preload("Details").Preload("Preferences").Where("uuid = ?", uuid).First(&u).Error; err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return fiber.NewError(fiber.StatusNotFound, "user not found")
|
return fiber.NewError(fiber.StatusNotFound, "user not found")
|
||||||
}
|
}
|
||||||
|
|
@ -106,17 +100,17 @@ func (ac *AdminController) BlockUser(c fiber.Ctx) error {
|
||||||
|
|
||||||
switch req.Action {
|
switch req.Action {
|
||||||
case "block":
|
case "block":
|
||||||
user.Status = models.UserStatusDisabled
|
u.Status = users.UserStatusDisabled
|
||||||
case "unblock":
|
case "unblock":
|
||||||
user.Status = models.UserStatusActive
|
u.Status = users.UserStatusActive
|
||||||
default:
|
default:
|
||||||
return fiber.NewError(fiber.StatusBadRequest, "invalid action")
|
return fiber.NewError(fiber.StatusBadRequest, "invalid action")
|
||||||
}
|
}
|
||||||
user.UpdatedAt = time.Now().UTC()
|
u.UpdatedAt = time.Now().UTC()
|
||||||
|
|
||||||
if err := db.Save(&user).Error; err != nil {
|
if err := db.Save(&u).Error; err != nil {
|
||||||
return fiber.NewError(fiber.StatusInternalServerError, "failed to update user status")
|
return fiber.NewError(fiber.StatusInternalServerError, "failed to update user status")
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(responses.Success(models.ToUserShort(&user)))
|
return c.JSON(responses.Success(u))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
package admin
|
package admin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"server/internal/authorization"
|
"server/internal/roles"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v3"
|
"github.com/gofiber/fiber/v3"
|
||||||
)
|
)
|
||||||
|
|
@ -11,9 +11,9 @@ func RegisterAdminRoutes(app *fiber.App) {
|
||||||
|
|
||||||
// Typescript: TSEndpoint= path=/admin/users; name=listUsers; method=POST; request=admin.ListUsersRequest; response=models.[]UserShort
|
// Typescript: TSEndpoint= path=/admin/users; name=listUsers; method=POST; request=admin.ListUsersRequest; response=models.[]UserShort
|
||||||
app.Post("/admin/users", adminController.ListUsers)
|
app.Post("/admin/users", adminController.ListUsers)
|
||||||
authorization.RegisterEndpoint("POST/admin/users", int(authorization.AdminPermission))
|
roles.RegisterEndpoint("POST/admin/users", int(roles.AdminPermission))
|
||||||
|
|
||||||
// Typescript: TSEndpoint= path=/admin/users/:uuid/block; name=blockUser; method=PUT; request=admin.BlockUserRequest; response=models.UserShort
|
// Typescript: TSEndpoint= path=/admin/users/:uuid/block; name=blockUser; method=PUT; request=admin.BlockUserRequest; response=models.UserShort
|
||||||
app.Put("/admin/users/:uuid/block", adminController.BlockUser)
|
app.Put("/admin/users/:uuid/block", adminController.BlockUser)
|
||||||
authorization.RegisterEndpoint("PUT/admin/users/:uuid/block", int(authorization.AdminPermission))
|
roles.RegisterEndpoint("PUT/admin/users/:uuid/block", int(roles.AdminPermission))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,380 +0,0 @@
|
||||||
package auth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/rand"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"server/internal/helpers"
|
|
||||||
"server/internal/mail"
|
|
||||||
"server/internal/models"
|
|
||||||
"server/internal/responses"
|
|
||||||
"server/internal/tokens"
|
|
||||||
"server/internal/validation"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v3"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
type AuthController struct {
|
|
||||||
authService *AuthService
|
|
||||||
mailService *mail.Service
|
|
||||||
}
|
|
||||||
|
|
||||||
func New(authService *AuthService, mailService *mail.Service) *AuthController {
|
|
||||||
return &AuthController{
|
|
||||||
authService: authService,
|
|
||||||
mailService: mailService,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Login authenticates a user and issues an access/refresh token pair.
|
|
||||||
func (ac *AuthController) Login(c fiber.Ctx) error {
|
|
||||||
var req LoginRequest
|
|
||||||
if err := c.Bind().Body(&req); err != nil {
|
|
||||||
return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
|
|
||||||
}
|
|
||||||
if err := validation.ValidateStruct(&req); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
db, err := helpers.DBFromCtx(c)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
var user models.User
|
|
||||||
if err := db.Where("email = ?", req.Username).First(&user).Error; err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return fiber.NewError(fiber.StatusUnauthorized, "invalid credentials")
|
|
||||||
}
|
|
||||||
return fiber.NewError(fiber.StatusInternalServerError, "failed to fetch user")
|
|
||||||
}
|
|
||||||
match, err := VerifyPassword(user.Password, req.Password)
|
|
||||||
if err != nil {
|
|
||||||
return fiber.NewError(fiber.StatusInternalServerError, "failed to verify credentials")
|
|
||||||
}
|
|
||||||
if !match {
|
|
||||||
return fiber.NewError(fiber.StatusUnauthorized, "invalid credentials")
|
|
||||||
}
|
|
||||||
|
|
||||||
token, err := ac.authService.GenerateTokenPair(user.Email)
|
|
||||||
if err != nil {
|
|
||||||
return fiber.NewError(fiber.StatusInternalServerError, "failed to issue token")
|
|
||||||
}
|
|
||||||
|
|
||||||
userID := user.ID
|
|
||||||
now := time.Now().UTC()
|
|
||||||
if err := db.Where("expires_at < ?", now).Delete(&models.Session{}).Error; err != nil {
|
|
||||||
return fiber.NewError(fiber.StatusInternalServerError, "failed to purge expired sessions")
|
|
||||||
}
|
|
||||||
|
|
||||||
session := models.Session{
|
|
||||||
UserID: &userID,
|
|
||||||
Username: user.Email,
|
|
||||||
AccessTokenHash: tokens.HashToken(token.AccessToken),
|
|
||||||
RefreshTokenHash: tokens.HashToken(token.RefreshToken),
|
|
||||||
ExpiresAt: now.Add(ac.authService.RefreshExpiry()),
|
|
||||||
IPAddress: c.IP(),
|
|
||||||
UserAgent: c.Get("User-Agent"),
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := db.Create(&session).Error; err != nil {
|
|
||||||
return fiber.NewError(fiber.StatusInternalServerError, "failed to record session")
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Response().Header.Set("Auth-Token", token.AccessToken)
|
|
||||||
|
|
||||||
return c.JSON(responses.Success(token))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refresh renews an access/refresh token pair using a valid refresh token.
|
|
||||||
func (ac *AuthController) Refresh(c fiber.Ctx) error {
|
|
||||||
var req RefreshRequest
|
|
||||||
if err := c.Bind().Body(&req); err != nil {
|
|
||||||
return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
|
|
||||||
}
|
|
||||||
if req.RefreshToken == "" {
|
|
||||||
return fiber.NewError(fiber.StatusBadRequest, "refresh_token is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
tokens, err := ac.authService.Refresh(req.RefreshToken)
|
|
||||||
if err != nil {
|
|
||||||
return fiber.NewError(fiber.StatusUnauthorized, err.Error())
|
|
||||||
}
|
|
||||||
return c.JSON(responses.Success(tokens))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register creates a new user with optional roles/types/preferences.
|
|
||||||
func (ac *AuthController) Register(c fiber.Ctx) error {
|
|
||||||
var req models.UserCreateInput
|
|
||||||
if err := c.Bind().Body(&req); err != nil {
|
|
||||||
return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
|
|
||||||
}
|
|
||||||
if err := validation.ValidateStruct(&req); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
db, err := helpers.DBFromCtx(c)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
var existing models.User
|
|
||||||
if err := db.Where("email = ?", req.Email).First(&existing).Error; err == nil {
|
|
||||||
return fiber.NewError(fiber.StatusConflict, "user already exists")
|
|
||||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return fiber.NewError(fiber.StatusInternalServerError, "failed to check user")
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now().UTC()
|
|
||||||
hashedPassword, err := HashPassword(req.Password)
|
|
||||||
if err != nil {
|
|
||||||
return fiber.NewError(fiber.StatusInternalServerError, "failed to secure password")
|
|
||||||
}
|
|
||||||
user := models.User{
|
|
||||||
Email: req.Email,
|
|
||||||
Name: req.Name,
|
|
||||||
Password: hashedPassword,
|
|
||||||
Roles: func() models.UserRoles {
|
|
||||||
if len(req.Roles) == 0 {
|
|
||||||
return models.UserRoles{"user"}
|
|
||||||
}
|
|
||||||
return req.Roles
|
|
||||||
}(),
|
|
||||||
Status: func() models.UserStatus {
|
|
||||||
if req.Status == "" {
|
|
||||||
return models.UserStatusPending
|
|
||||||
}
|
|
||||||
return req.Status
|
|
||||||
}(),
|
|
||||||
Types: func() models.UserTypes {
|
|
||||||
if len(req.Types) == 0 {
|
|
||||||
return models.UserTypes{"internal"}
|
|
||||||
}
|
|
||||||
return req.Types
|
|
||||||
}(),
|
|
||||||
Avatar: req.Avatar,
|
|
||||||
UUID: uuid.NewString(),
|
|
||||||
Details: helpers.ToUserDetails(req.Details),
|
|
||||||
Preferences: func() *models.UserPreferences {
|
|
||||||
if req.Preferences == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &models.UserPreferences{
|
|
||||||
UseIdle: req.Preferences.UseIdle,
|
|
||||||
IdleTimeout: req.Preferences.IdleTimeout,
|
|
||||||
UseIdlePassword: req.Preferences.UseIdlePassword,
|
|
||||||
IdlePin: req.Preferences.IdlePin,
|
|
||||||
UseDirectLogin: req.Preferences.UseDirectLogin,
|
|
||||||
UseQuadcodeLogin: req.Preferences.UseQuadcodeLogin,
|
|
||||||
SendNoticesMail: req.Preferences.SendNoticesMail,
|
|
||||||
Language: req.Preferences.Language,
|
|
||||||
}
|
|
||||||
}(),
|
|
||||||
CreatedAt: now,
|
|
||||||
UpdatedAt: now,
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := db.Create(&user).Error; err != nil {
|
|
||||||
return fiber.NewError(fiber.StatusInternalServerError, "failed to create user")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := ac.mailService.Send(c, mail.Message{
|
|
||||||
To: user.Email,
|
|
||||||
Subject: fmt.Sprintf("[%s] Registrazione completata", ac.mailService.AppName()),
|
|
||||||
Template: "registration",
|
|
||||||
TemplateData: mail.TemplateData{
|
|
||||||
AppName: ac.mailService.AppName(),
|
|
||||||
UserName: user.Name,
|
|
||||||
UserEmail: user.Email,
|
|
||||||
},
|
|
||||||
}); err != nil {
|
|
||||||
return fiber.NewError(fiber.StatusInternalServerError, "failed to send registration email")
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.Status(fiber.StatusCreated).JSON(responses.Success(models.ToUserShort(&user)))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ac *AuthController) ForgotPassword(c fiber.Ctx) error {
|
|
||||||
var req ForgotPasswordRequest
|
|
||||||
if err := c.Bind().Body(&req); err != nil {
|
|
||||||
return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
|
|
||||||
}
|
|
||||||
if err := validation.ValidateStruct(&req); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
db, err := helpers.DBFromCtx(c)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
var user models.User
|
|
||||||
if err := db.Where("email = ?", req.Email).First(&user).Error; err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return c.JSON(responses.Success(fiber.Map{"sent": true}))
|
|
||||||
}
|
|
||||||
return fiber.NewError(fiber.StatusInternalServerError, "failed to load user")
|
|
||||||
}
|
|
||||||
|
|
||||||
if user.Status == models.UserStatusDisabled {
|
|
||||||
return c.JSON(responses.Success(fiber.Map{"sent": true}))
|
|
||||||
}
|
|
||||||
|
|
||||||
resetToken, err := generateSecureToken()
|
|
||||||
if err != nil {
|
|
||||||
return fiber.NewError(fiber.StatusInternalServerError, "failed to generate reset token")
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now().UTC()
|
|
||||||
record := models.PasswordResetToken{
|
|
||||||
UserID: user.ID,
|
|
||||||
TokenHash: tokens.HashToken(resetToken),
|
|
||||||
ExpiresAt: now.Add(30 * time.Minute),
|
|
||||||
CreatedAt: now,
|
|
||||||
UpdatedAt: now,
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := db.Transaction(func(tx *gorm.DB) error {
|
|
||||||
if err := tx.Where("user_id = ? OR expires_at < ? OR used_at IS NOT NULL", user.ID, now).
|
|
||||||
Delete(&models.PasswordResetToken{}).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return tx.Create(&record).Error
|
|
||||||
}); err != nil {
|
|
||||||
return fiber.NewError(fiber.StatusInternalServerError, "failed to store reset token")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := ac.mailService.Send(c, mail.Message{
|
|
||||||
To: user.Email,
|
|
||||||
Subject: fmt.Sprintf("[%s] Recupero password", ac.mailService.AppName()),
|
|
||||||
Template: "password_reset",
|
|
||||||
TemplateData: mail.TemplateData{
|
|
||||||
AppName: ac.mailService.AppName(),
|
|
||||||
UserName: user.Name,
|
|
||||||
UserEmail: user.Email,
|
|
||||||
ResetToken: resetToken,
|
|
||||||
ResetURL: ac.mailService.ResetLink(resetToken),
|
|
||||||
},
|
|
||||||
}); err != nil {
|
|
||||||
return fiber.NewError(fiber.StatusInternalServerError, "failed to send reset email")
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.JSON(responses.Success(responses.SimpleResponse{Message: "password reset email sent"}))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ac *AuthController) ResetPassword(c fiber.Ctx) error {
|
|
||||||
var req ResetPasswordRequest
|
|
||||||
if err := c.Bind().Body(&req); err != nil {
|
|
||||||
return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
|
|
||||||
}
|
|
||||||
if err := validation.ValidateStruct(&req); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
db, err := helpers.DBFromCtx(c)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
hashedPassword, err := HashPassword(req.Password)
|
|
||||||
if err != nil {
|
|
||||||
return fiber.NewError(fiber.StatusInternalServerError, "failed to secure password")
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now().UTC()
|
|
||||||
tokenHash := tokens.HashToken(req.Token)
|
|
||||||
if err := db.Transaction(func(tx *gorm.DB) error {
|
|
||||||
var resetToken models.PasswordResetToken
|
|
||||||
if err := tx.Where("token_hash = ?", tokenHash).First(&resetToken).Error; err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return fiber.NewError(fiber.StatusBadRequest, "invalid or expired reset token")
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if resetToken.UsedAt != nil || now.After(resetToken.ExpiresAt) {
|
|
||||||
return fiber.NewError(fiber.StatusBadRequest, "invalid or expired reset token")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Model(&models.User{}).Where("id = ?", resetToken.UserID).Updates(map[string]any{
|
|
||||||
"password": hashedPassword,
|
|
||||||
"updated_at": now,
|
|
||||||
}).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := tx.Model(&resetToken).Updates(map[string]any{
|
|
||||||
"used_at": now,
|
|
||||||
"updated_at": now,
|
|
||||||
}).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := tx.Where("user_id = ?", resetToken.UserID).Delete(&models.Session{}).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := tx.Where("user_id = ? AND id <> ?", resetToken.UserID, resetToken.ID).Delete(&models.PasswordResetToken{}).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}); err != nil {
|
|
||||||
var fiberErr *fiber.Error
|
|
||||||
if errors.As(err, &fiberErr) {
|
|
||||||
return fiberErr
|
|
||||||
}
|
|
||||||
return fiber.NewError(fiber.StatusInternalServerError, "failed to reset password")
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.JSON(responses.Success(responses.SimpleResponse{Message: "password reset successful"}))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ac *AuthController) ValidToken(c fiber.Ctx) error {
|
|
||||||
raw := strings.TrimSpace(string(c.Body()))
|
|
||||||
if raw == "" {
|
|
||||||
return fiber.NewError(fiber.StatusBadRequest, "token is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
token := raw
|
|
||||||
if strings.HasPrefix(raw, "\"") && strings.HasSuffix(raw, "\"") {
|
|
||||||
if err := json.Unmarshal([]byte(raw), &token); err != nil {
|
|
||||||
return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
token = strings.TrimSpace(token)
|
|
||||||
if token == "" {
|
|
||||||
return fiber.NewError(fiber.StatusBadRequest, "token is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
db, err := helpers.DBFromCtx(c)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now().UTC()
|
|
||||||
tokenHash := tokens.HashToken(token)
|
|
||||||
var resetToken models.PasswordResetToken
|
|
||||||
if err := db.Where("token_hash = ?", tokenHash).First(&resetToken).Error; err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return fiber.NewError(fiber.StatusBadRequest, "invalid or expired reset token")
|
|
||||||
}
|
|
||||||
return fiber.NewError(fiber.StatusInternalServerError, "failed to validate reset token")
|
|
||||||
}
|
|
||||||
|
|
||||||
if resetToken.UsedAt != nil || now.After(resetToken.ExpiresAt) {
|
|
||||||
return fiber.NewError(fiber.StatusBadRequest, "invalid or expired reset token")
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.JSON(responses.Success(responses.SimpleResponse{Message: "valid reset token"}))
|
|
||||||
}
|
|
||||||
|
|
||||||
func generateSecureToken() (string, error) {
|
|
||||||
buf := make([]byte, 32)
|
|
||||||
if _, err := rand.Read(buf); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return base64.RawURLEncoding.EncodeToString(buf), nil
|
|
||||||
}
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
package auth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/golang-jwt/jwt/v5"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Config struct {
|
|
||||||
Secret string
|
|
||||||
Issuer string
|
|
||||||
AccessTokenExpiry time.Duration
|
|
||||||
RefreshTokenExpiry time.Duration
|
|
||||||
}
|
|
||||||
|
|
||||||
type Claims struct {
|
|
||||||
Username string `json:"username"`
|
|
||||||
TokenType string `json:"type"`
|
|
||||||
jwt.RegisteredClaims
|
|
||||||
}
|
|
||||||
|
|
||||||
// Typescript: interface
|
|
||||||
type TokenPair struct {
|
|
||||||
AccessToken string `json:"access_token"`
|
|
||||||
RefreshToken string `json:"refresh_token"`
|
|
||||||
}
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
package auth
|
|
||||||
|
|
||||||
// Typescript: interface
|
|
||||||
type LoginRequest struct {
|
|
||||||
Username string `json:"username" validate:"required,email"`
|
|
||||||
Password string `json:"password" validate:"required,min=8,max=128"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Typescript: interface
|
|
||||||
type RefreshRequest struct {
|
|
||||||
RefreshToken string `json:"refresh_token"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Typescript: interface
|
|
||||||
type ForgotPasswordRequest struct {
|
|
||||||
Email string `json:"email" validate:"required,email"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Typescript: interface
|
|
||||||
type ResetPasswordRequest struct {
|
|
||||||
Token string `json:"token" validate:"required,min=20,max=255"`
|
|
||||||
Password string `json:"password" validate:"required,min=8,max=128"`
|
|
||||||
}
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
package auth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"server/internal/mail"
|
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v3"
|
|
||||||
"github.com/gofiber/fiber/v3/middleware/limiter"
|
|
||||||
)
|
|
||||||
|
|
||||||
func Register(app *fiber.App, authService *AuthService, mailService *mail.Service) {
|
|
||||||
authController := New(authService, mailService)
|
|
||||||
authRateLimiter := limiter.New(limiter.Config{
|
|
||||||
Max: 10,
|
|
||||||
Expiration: time.Minute,
|
|
||||||
LimiterMiddleware: limiter.SlidingWindow{},
|
|
||||||
})
|
|
||||||
|
|
||||||
// Typescript: TSEndpoint= path=/auth/login; name=login; method=POST; request=model.LoginRequest; response=model.TokenPair
|
|
||||||
app.Post("/auth/login", authRateLimiter, authController.Login)
|
|
||||||
|
|
||||||
// Typescript: TSEndpoint= path=/auth/refresh; name=refresh; method=POST; request=model.RefreshRequest; response=model.TokenPair
|
|
||||||
app.Post("/auth/refresh", authService.Middleware(), authController.Refresh)
|
|
||||||
|
|
||||||
// Typescript: TSEndpoint= path=/auth/register; name=register; method=POST; request=models.UserCreateInput; response=models.UserShort
|
|
||||||
app.Post("/auth/register", authRateLimiter, authController.Register)
|
|
||||||
|
|
||||||
// Typescript: TSEndpoint= path=/auth/password/forgot; name=forgotPassword; method=POST; request=model.ForgotPasswordRequest; response=controllers.SimpleResponse
|
|
||||||
app.Post("/auth/password/forgot", authRateLimiter, authController.ForgotPassword)
|
|
||||||
|
|
||||||
// Typescript: TSEndpoint= path=/auth/password/reset; name=resetPassword; method=POST; request=model.ResetPasswordRequest; response=controllers.SimpleResponse
|
|
||||||
app.Post("/auth/password/reset", authRateLimiter, authController.ResetPassword)
|
|
||||||
|
|
||||||
// Typescript: TSEndpoint= path=/auth/password/valid; name=validToken; method=POST; request=string; response=controllers.SimpleResponse
|
|
||||||
app.Post("/auth/password/valid", authRateLimiter, authController.ValidToken)
|
|
||||||
}
|
|
||||||
|
|
@ -1,149 +0,0 @@
|
||||||
package auth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v3"
|
|
||||||
"github.com/golang-jwt/jwt/v5"
|
|
||||||
)
|
|
||||||
|
|
||||||
type AuthService struct {
|
|
||||||
cfg Config
|
|
||||||
secret []byte
|
|
||||||
accessExpiry time.Duration
|
|
||||||
refreshExpiry time.Duration
|
|
||||||
}
|
|
||||||
|
|
||||||
const (
|
|
||||||
tokenTypeAccess = "access"
|
|
||||||
tokenTypeRefresh = "refresh"
|
|
||||||
)
|
|
||||||
|
|
||||||
func NewAuthService(cfg Config) (*AuthService, error) {
|
|
||||||
if cfg.Secret == "" {
|
|
||||||
return nil, errors.New("jwt secret is required")
|
|
||||||
}
|
|
||||||
if cfg.AccessTokenExpiry <= 0 {
|
|
||||||
return nil, errors.New("access token expiry must be positive")
|
|
||||||
}
|
|
||||||
if cfg.RefreshTokenExpiry <= 0 {
|
|
||||||
return nil, errors.New("refresh token expiry must be positive")
|
|
||||||
}
|
|
||||||
|
|
||||||
return &AuthService{
|
|
||||||
cfg: cfg,
|
|
||||||
secret: []byte(cfg.Secret),
|
|
||||||
accessExpiry: cfg.AccessTokenExpiry,
|
|
||||||
refreshExpiry: cfg.RefreshTokenExpiry,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *AuthService) GenerateTokenPair(username string) (TokenPair, error) {
|
|
||||||
access, err := s.generateToken(username, tokenTypeAccess, s.accessExpiry)
|
|
||||||
if err != nil {
|
|
||||||
return TokenPair{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
refresh, err := s.generateToken(username, tokenTypeRefresh, s.refreshExpiry)
|
|
||||||
if err != nil {
|
|
||||||
return TokenPair{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return TokenPair{
|
|
||||||
AccessToken: access,
|
|
||||||
RefreshToken: refresh,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *AuthService) AccessExpiry() time.Duration {
|
|
||||||
return s.accessExpiry
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *AuthService) RefreshExpiry() time.Duration {
|
|
||||||
return s.refreshExpiry
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *AuthService) Middleware() fiber.Handler {
|
|
||||||
return func(c fiber.Ctx) error {
|
|
||||||
tokenString := c.Get("Auth-Token")
|
|
||||||
if tokenString == "" {
|
|
||||||
return fiber.NewError(fiber.StatusUnauthorized, "missing token header")
|
|
||||||
}
|
|
||||||
|
|
||||||
claims, err := s.parseToken(tokenString)
|
|
||||||
if err != nil {
|
|
||||||
return fiber.NewError(fiber.StatusUnauthorized, err.Error())
|
|
||||||
}
|
|
||||||
if claims.TokenType != tokenTypeAccess {
|
|
||||||
return fiber.NewError(fiber.StatusUnauthorized, "access token required")
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Locals("authClaims", claims)
|
|
||||||
return c.Next()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *AuthService) Refresh(refreshToken string) (TokenPair, error) {
|
|
||||||
claims, err := s.parseToken(refreshToken)
|
|
||||||
if err != nil {
|
|
||||||
return TokenPair{}, err
|
|
||||||
}
|
|
||||||
if claims.TokenType != tokenTypeRefresh {
|
|
||||||
return TokenPair{}, errors.New("refresh token required")
|
|
||||||
}
|
|
||||||
return s.GenerateTokenPair(claims.Username)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *AuthService) ValidateAccessToken(tokenString string) (*Claims, error) {
|
|
||||||
claims, err := s.parseToken(tokenString)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if claims.TokenType != tokenTypeAccess {
|
|
||||||
return nil, errors.New("access token required")
|
|
||||||
}
|
|
||||||
return claims, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func ClaimsFromCtx(c fiber.Ctx) (*Claims, bool) {
|
|
||||||
val := c.Locals("authClaims")
|
|
||||||
if val == nil {
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
claims, ok := val.(*Claims)
|
|
||||||
return claims, ok
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *AuthService) parseToken(tokenString string) (*Claims, error) {
|
|
||||||
claims := &Claims{}
|
|
||||||
token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) {
|
|
||||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
|
||||||
return nil, fiber.ErrUnauthorized
|
|
||||||
}
|
|
||||||
return s.secret, nil
|
|
||||||
})
|
|
||||||
if err != nil || !token.Valid {
|
|
||||||
return nil, errors.New("invalid or expired token")
|
|
||||||
}
|
|
||||||
if s.cfg.Issuer != "" && claims.Issuer != "" && claims.Issuer != s.cfg.Issuer {
|
|
||||||
return nil, errors.New("invalid token issuer")
|
|
||||||
}
|
|
||||||
|
|
||||||
return claims, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *AuthService) generateToken(username, tokenType string, expiry time.Duration) (string, error) {
|
|
||||||
claims := Claims{
|
|
||||||
Username: username,
|
|
||||||
TokenType: tokenType,
|
|
||||||
RegisteredClaims: jwt.RegisteredClaims{
|
|
||||||
Issuer: s.cfg.Issuer,
|
|
||||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(expiry)),
|
|
||||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
|
||||||
return token.SignedString(s.secret)
|
|
||||||
}
|
|
||||||
|
|
@ -14,6 +14,7 @@ type ServerConfig struct {
|
||||||
DisableStartupMessage bool `json:"disable_startup_message"`
|
DisableStartupMessage bool `json:"disable_startup_message"`
|
||||||
Auth AuthConfig `json:"auth"`
|
Auth AuthConfig `json:"auth"`
|
||||||
Mail MailConfig `json:"mail"`
|
Mail MailConfig `json:"mail"`
|
||||||
|
Db DbConfig `json:"db_config"`
|
||||||
RolesConfigPath string `json:"roles_config_path"`
|
RolesConfigPath string `json:"roles_config_path"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -42,23 +43,54 @@ type SMTPMailConfig struct {
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func LoadConfig(path string) (ServerConfig, error) {
|
type DbConfig struct {
|
||||||
|
Driver string
|
||||||
|
DSN string
|
||||||
|
}
|
||||||
|
|
||||||
|
var Config *ServerConfig = nil
|
||||||
|
|
||||||
|
func GetConfig() (*ServerConfig, error) {
|
||||||
|
if Config == nil {
|
||||||
|
var err error
|
||||||
|
Config, err = loadConfig()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Failed to load config: %v\n", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Config, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func envOrDefault(key, defaultValue string) string {
|
||||||
|
if value, exists := os.LookupEnv(key); exists {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadConfig() (*ServerConfig, error) {
|
||||||
|
path := envOrDefault("CONFIG_PATH", "configs/config.json")
|
||||||
|
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ServerConfig{}, fmt.Errorf("read config: %w", err)
|
return nil, fmt.Errorf("read config: %w", err)
|
||||||
}
|
}
|
||||||
var cfg ServerConfig
|
var cfg ServerConfig
|
||||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||||
return ServerConfig{}, fmt.Errorf("parse config: %w", err)
|
return nil, fmt.Errorf("parse config: %w", err)
|
||||||
|
}
|
||||||
|
if secret := os.Getenv("AUTH_SECRET"); secret != "" {
|
||||||
|
cfg.Auth.Secret = secret
|
||||||
}
|
}
|
||||||
if cfg.Auth.Secret == "" {
|
if cfg.Auth.Secret == "" {
|
||||||
return ServerConfig{}, fmt.Errorf("auth.secret must be set")
|
return nil, fmt.Errorf("auth.secret must be set")
|
||||||
}
|
}
|
||||||
if cfg.Auth.AccessTokenExpiryMinutes <= 0 {
|
if cfg.Auth.AccessTokenExpiryMinutes <= 0 {
|
||||||
return ServerConfig{}, fmt.Errorf("auth.access_token_expiry_minutes must be greater than zero")
|
return nil, fmt.Errorf("auth.access_token_expiry_minutes must be greater than zero")
|
||||||
}
|
}
|
||||||
if cfg.Auth.RefreshTokenExpiryMinutes <= 0 {
|
if cfg.Auth.RefreshTokenExpiryMinutes <= 0 {
|
||||||
return ServerConfig{}, fmt.Errorf("auth.refresh_token_expiry_minutes must be greater than zero")
|
return nil, fmt.Errorf("auth.refresh_token_expiry_minutes must be greater than zero")
|
||||||
}
|
}
|
||||||
if cfg.Mail.Mode == "" {
|
if cfg.Mail.Mode == "" {
|
||||||
cfg.Mail.Mode = "file"
|
cfg.Mail.Mode = "file"
|
||||||
|
|
@ -70,21 +102,26 @@ func LoadConfig(path string) (ServerConfig, error) {
|
||||||
cfg.Mail.ResetPasswordPath = "/#reset-password"
|
cfg.Mail.ResetPasswordPath = "/#reset-password"
|
||||||
}
|
}
|
||||||
if cfg.Mail.Mode != "smtp" && cfg.Mail.Mode != "file" {
|
if cfg.Mail.Mode != "smtp" && cfg.Mail.Mode != "file" {
|
||||||
return ServerConfig{}, fmt.Errorf("mail.mode must be either smtp or file")
|
return nil, fmt.Errorf("mail.mode must be either smtp or file")
|
||||||
}
|
}
|
||||||
if cfg.Mail.From == "" {
|
if cfg.Mail.From == "" {
|
||||||
return ServerConfig{}, fmt.Errorf("mail.from must be set")
|
return nil, fmt.Errorf("mail.from must be set")
|
||||||
}
|
}
|
||||||
if cfg.Mail.Mode == "smtp" {
|
if cfg.Mail.Mode == "smtp" {
|
||||||
if cfg.Mail.SMTP.Host == "" {
|
if cfg.Mail.SMTP.Host == "" {
|
||||||
return ServerConfig{}, fmt.Errorf("mail.smtp.host must be set when mail.mode=smtp")
|
return nil, fmt.Errorf("mail.smtp.host must be set when mail.mode=smtp")
|
||||||
}
|
}
|
||||||
if cfg.Mail.SMTP.Port <= 0 {
|
if cfg.Mail.SMTP.Port <= 0 {
|
||||||
return ServerConfig{}, fmt.Errorf("mail.smtp.port must be greater than zero when mail.mode=smtp")
|
return nil, fmt.Errorf("mail.smtp.port must be greater than zero when mail.mode=smtp")
|
||||||
}
|
}
|
||||||
} else if cfg.Mail.DebugDir == "" {
|
} else if cfg.Mail.DebugDir == "" {
|
||||||
cfg.Mail.DebugDir = "data/mail-debug"
|
cfg.Mail.DebugDir = "data/mail-debug"
|
||||||
}
|
}
|
||||||
|
|
||||||
return cfg, nil
|
cfg.Db = DbConfig{
|
||||||
|
Driver: envOrDefault("DB_driver", "sqlite"),
|
||||||
|
DSN: envOrDefault("DB_dsn", "file:./data/data.db?_foreign_keys=on"),
|
||||||
|
}
|
||||||
|
|
||||||
|
return &cfg, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,44 +4,51 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"server/internal/config"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"server/internal/models"
|
"github.com/gofiber/fiber/v3"
|
||||||
|
|
||||||
"gorm.io/driver/postgres"
|
"gorm.io/driver/postgres"
|
||||||
"gorm.io/driver/sqlite"
|
"gorm.io/driver/sqlite"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
var DB *gorm.DB
|
||||||
Driver string
|
|
||||||
DSN string
|
// GetDB returns the global *gorm.DB instance. It panics if the database is not initialized.
|
||||||
|
func GetDB() (*gorm.DB, error) {
|
||||||
|
if DB == nil {
|
||||||
|
cfg, err := config.GetConfig()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
DB, err = InitDB(cfg.Db)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Failed to initialize database: %v\n", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return DB, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Init opens the configured database connection and runs schema migrations.
|
// Init opens the configured database connection and runs schema migrations.
|
||||||
func Init(cfg Config) (*gorm.DB, error) {
|
func InitDB(cfg config.DbConfig) (*gorm.DB, error) {
|
||||||
switch cfg.Driver {
|
switch cfg.Driver {
|
||||||
case "sqlite":
|
case "sqlite":
|
||||||
if err := ensureSQLiteDir(cfg.DSN); err != nil {
|
if err := ensureSQLiteDir(cfg.DSN); err != nil {
|
||||||
return nil, fmt.Errorf("prepare sqlite path: %w", err)
|
return nil, fmt.Errorf("prepare sqlite path: %w", err)
|
||||||
}
|
}
|
||||||
db, err := gorm.Open(sqlite.Open(cfg.DSN), &gorm.Config{})
|
DB, err := gorm.Open(sqlite.Open(cfg.DSN), &gorm.Config{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("open sqlite: %w", err)
|
return nil, fmt.Errorf("open sqlite: %w", err)
|
||||||
}
|
}
|
||||||
if err := db.AutoMigrate(&models.User{}, &models.UserDetails{}, &models.UserPreferences{}, &models.Session{}, &models.PasswordResetToken{}); err != nil {
|
return DB, nil
|
||||||
return nil, fmt.Errorf("migrate user: %w", err)
|
|
||||||
}
|
|
||||||
return db, nil
|
|
||||||
case "postgres":
|
case "postgres":
|
||||||
db, err := gorm.Open(postgres.Open(cfg.DSN), &gorm.Config{})
|
DB, err := gorm.Open(postgres.Open(cfg.DSN), &gorm.Config{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("open postgres: %w", err)
|
return nil, fmt.Errorf("open postgres: %w", err)
|
||||||
}
|
}
|
||||||
if err := db.AutoMigrate(&models.User{}, &models.UserDetails{}, &models.UserPreferences{}, &models.Session{}, &models.PasswordResetToken{}); err != nil {
|
return DB, nil
|
||||||
return nil, fmt.Errorf("migrate user: %w", err)
|
|
||||||
}
|
|
||||||
return db, nil
|
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unsupported driver %q", cfg.Driver)
|
return nil, fmt.Errorf("unsupported driver %q", cfg.Driver)
|
||||||
}
|
}
|
||||||
|
|
@ -62,3 +69,17 @@ func ensureSQLiteDir(dsn string) error {
|
||||||
}
|
}
|
||||||
return os.MkdirAll(dir, 0o755)
|
return os.MkdirAll(dir, 0o755)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dbFromCtx extracts *gorm.DB from Fiber context.
|
||||||
|
func dbFromCtx(c fiber.Ctx) (*gorm.DB, error) {
|
||||||
|
dbVal := c.Locals("db")
|
||||||
|
db, ok := dbVal.(*gorm.DB)
|
||||||
|
if !ok || db == nil {
|
||||||
|
return nil, fiber.NewError(fiber.StatusInternalServerError, "database unavailable")
|
||||||
|
}
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func DBFromCtx(c fiber.Ctx) (*gorm.DB, error) {
|
||||||
|
return dbFromCtx(c)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
package helpers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"server/internal/models"
|
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v3"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
// dbFromCtx extracts *gorm.DB from Fiber context.
|
|
||||||
func dbFromCtx(c fiber.Ctx) (*gorm.DB, error) {
|
|
||||||
dbVal := c.Locals("db")
|
|
||||||
db, ok := dbVal.(*gorm.DB)
|
|
||||||
if !ok || db == nil {
|
|
||||||
return nil, fiber.NewError(fiber.StatusInternalServerError, "database unavailable")
|
|
||||||
}
|
|
||||||
return db, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func DBFromCtx(c fiber.Ctx) (*gorm.DB, error) {
|
|
||||||
return dbFromCtx(c)
|
|
||||||
}
|
|
||||||
|
|
||||||
func toUserDetails(d *models.UserDetailsShort) *models.UserDetails {
|
|
||||||
if d == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &models.UserDetails{
|
|
||||||
Title: d.Title,
|
|
||||||
FirstName: d.FirstName,
|
|
||||||
LastName: d.LastName,
|
|
||||||
Address: d.Address,
|
|
||||||
City: d.City,
|
|
||||||
ZipCode: d.ZipCode,
|
|
||||||
Country: d.Country,
|
|
||||||
Phone: d.Phone,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func ToUserDetails(d *models.UserDetailsShort) *models.UserDetails {
|
|
||||||
return toUserDetails(d)
|
|
||||||
}
|
|
||||||
|
|
||||||
func toUserPreferences(p *models.UserPreferencesShort) *models.UserPreferences {
|
|
||||||
if p == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &models.UserPreferences{
|
|
||||||
UseIdle: p.UseIdle,
|
|
||||||
IdleTimeout: p.IdleTimeout,
|
|
||||||
UseIdlePassword: p.UseIdlePassword,
|
|
||||||
IdlePin: p.IdlePin,
|
|
||||||
UseDirectLogin: p.UseDirectLogin,
|
|
||||||
UseQuadcodeLogin: p.UseQuadcodeLogin,
|
|
||||||
SendNoticesMail: p.SendNoticesMail,
|
|
||||||
Language: p.Language,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func ToUserPreferences(p *models.UserPreferencesShort) *models.UserPreferences {
|
|
||||||
return toUserPreferences(p)
|
|
||||||
}
|
|
||||||
|
|
@ -10,6 +10,7 @@ import (
|
||||||
"net/smtp"
|
"net/smtp"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"server/internal/config"
|
||||||
"strings"
|
"strings"
|
||||||
texttemplate "text/template"
|
texttemplate "text/template"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -40,7 +41,7 @@ type Message struct {
|
||||||
TemplateData any
|
TemplateData any
|
||||||
}
|
}
|
||||||
|
|
||||||
type Service struct {
|
type MailService struct {
|
||||||
cfg Config
|
cfg Config
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -52,7 +53,30 @@ type TemplateData struct {
|
||||||
ResetToken string
|
ResetToken string
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(cfg Config) (*Service, error) {
|
// if service fail send admin allert instead a response to user or a simple response server error.
|
||||||
|
func New() (*MailService, error) {
|
||||||
|
|
||||||
|
serverCfg, err := config.GetConfig()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := Config{
|
||||||
|
AppName: serverCfg.AppName,
|
||||||
|
Mode: serverCfg.Mail.Mode,
|
||||||
|
From: serverCfg.Mail.From,
|
||||||
|
DebugDir: serverCfg.Mail.DebugDir,
|
||||||
|
TemplatesDir: serverCfg.Mail.TemplatesDir,
|
||||||
|
FrontendBaseURL: serverCfg.Mail.FrontendBaseURL,
|
||||||
|
ResetPasswordPath: serverCfg.Mail.ResetPasswordPath,
|
||||||
|
SMTP: SMTPConfig{
|
||||||
|
Host: serverCfg.Mail.SMTP.Host,
|
||||||
|
Port: serverCfg.Mail.SMTP.Port,
|
||||||
|
Username: serverCfg.Mail.SMTP.Username,
|
||||||
|
Password: serverCfg.Mail.SMTP.Password,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
if cfg.Mode != "smtp" && cfg.Mode != "file" {
|
if cfg.Mode != "smtp" && cfg.Mode != "file" {
|
||||||
return nil, fmt.Errorf("unsupported mail mode %q", cfg.Mode)
|
return nil, fmt.Errorf("unsupported mail mode %q", cfg.Mode)
|
||||||
}
|
}
|
||||||
|
|
@ -78,10 +102,10 @@ func New(cfg Config) (*Service, error) {
|
||||||
return nil, fmt.Errorf("smtp host and port are required")
|
return nil, fmt.Errorf("smtp host and port are required")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return &Service{cfg: cfg}, nil
|
return &MailService{cfg: cfg}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) Send(ctx context.Context, msg Message) error {
|
func (s *MailService) Send(ctx context.Context, msg Message) error {
|
||||||
htmlBody, textBody, err := s.renderBodies(msg.Template, msg.TemplateData)
|
htmlBody, textBody, err := s.renderBodies(msg.Template, msg.TemplateData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -98,7 +122,7 @@ func (s *Service) Send(ctx context.Context, msg Message) error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) ResetLink(token string) string {
|
func (s *MailService) ResetLink(token string) string {
|
||||||
base := strings.TrimRight(s.cfg.FrontendBaseURL, "/")
|
base := strings.TrimRight(s.cfg.FrontendBaseURL, "/")
|
||||||
path := s.cfg.ResetPasswordPath
|
path := s.cfg.ResetPasswordPath
|
||||||
if path == "" {
|
if path == "" {
|
||||||
|
|
@ -113,11 +137,11 @@ func (s *Service) ResetLink(token string) string {
|
||||||
return base + path + "?token=" + token
|
return base + path + "?token=" + token
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) AppName() string {
|
func (s *MailService) AppName() string {
|
||||||
return s.cfg.AppName
|
return s.cfg.AppName
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) renderBodies(templateName string, data any) (string, string, error) {
|
func (s *MailService) renderBodies(templateName string, data any) (string, string, error) {
|
||||||
htmlPath := filepath.Join(s.cfg.TemplatesDir, templateName+".html.tmpl")
|
htmlPath := filepath.Join(s.cfg.TemplatesDir, templateName+".html.tmpl")
|
||||||
textPath := filepath.Join(s.cfg.TemplatesDir, templateName+".txt.tmpl")
|
textPath := filepath.Join(s.cfg.TemplatesDir, templateName+".txt.tmpl")
|
||||||
|
|
||||||
|
|
@ -171,7 +195,7 @@ func buildMessage(from, to, subject, textBody, htmlBody string) []byte {
|
||||||
return []byte(strings.Join(append(headers, body...), "\r\n"))
|
return []byte(strings.Join(append(headers, body...), "\r\n"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) sendSMTP(ctx context.Context, to string, raw []byte) error {
|
func (s *MailService) sendSMTP(ctx context.Context, to string, raw []byte) error {
|
||||||
addr := fmt.Sprintf("%s:%d", s.cfg.SMTP.Host, s.cfg.SMTP.Port)
|
addr := fmt.Sprintf("%s:%d", s.cfg.SMTP.Host, s.cfg.SMTP.Port)
|
||||||
dialer := &net.Dialer{}
|
dialer := &net.Dialer{}
|
||||||
conn, err := dialer.DialContext(ctx, "tcp", addr)
|
conn, err := dialer.DialContext(ctx, "tcp", addr)
|
||||||
|
|
@ -221,7 +245,7 @@ func (s *Service) sendSMTP(ctx context.Context, to string, raw []byte) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) writeDebugMail(to, subject string, raw []byte) error {
|
func (s *MailService) writeDebugMail(to, subject string, raw []byte) error {
|
||||||
safeRecipient := strings.NewReplacer("@", "_at_", "/", "_", "\\", "_", ":", "_", " ", "_").Replace(to)
|
safeRecipient := strings.NewReplacer("@", "_at_", "/", "_", "\\", "_", ":", "_", " ", "_").Replace(to)
|
||||||
filename := fmt.Sprintf("%d_%s.eml", time.Now().UnixNano(), safeRecipient)
|
filename := fmt.Sprintf("%d_%s.eml", time.Now().UnixNano(), safeRecipient)
|
||||||
path := filepath.Join(s.cfg.DebugDir, filename)
|
path := filepath.Join(s.cfg.DebugDir, filename)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
users "server/internal/user"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func AutoMigrate(db *gorm.DB) error {
|
||||||
|
if err := db.AutoMigrate(&users.User{}, &users.UserDetails{}, &users.UserPreferences{}, &users.Session{}, &users.PasswordResetToken{}); err != nil {
|
||||||
|
return fmt.Errorf("migrate user: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
@ -8,13 +8,9 @@ type SimpleResponse struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// success wraps a payload in the standard API envelope.
|
// success wraps a payload in the standard API envelope.
|
||||||
func success(data any) fiber.Map {
|
func Success(data any) fiber.Map {
|
||||||
return fiber.Map{
|
return fiber.Map{
|
||||||
"data": data,
|
"data": data,
|
||||||
"error": nil,
|
"error": nil,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func Success(data any) fiber.Map {
|
|
||||||
return success(data)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,14 @@
|
||||||
package authorization
|
package roles
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"server/internal/auth"
|
"server/internal/tokens"
|
||||||
"server/internal/models"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v3"
|
"github.com/gofiber/fiber/v3"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
var Endpoints map[string]int
|
|
||||||
|
|
||||||
type Permission int
|
type Permission int
|
||||||
|
|
||||||
type Role struct {
|
type Role struct {
|
||||||
|
|
@ -38,6 +34,8 @@ var Roles = []Role{
|
||||||
{"guest", GuestPermission},
|
{"guest", GuestPermission},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var Endpoints map[string]int
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
Endpoints = make(map[string]int)
|
Endpoints = make(map[string]int)
|
||||||
}
|
}
|
||||||
|
|
@ -48,7 +46,7 @@ func RegisterEndpoint(key string, permission int) {
|
||||||
|
|
||||||
// RequireEndpointPermission enforces permission mapping defined in role config.
|
// RequireEndpointPermission enforces permission mapping defined in role config.
|
||||||
// If the endpoint is not configured, or mapped to "*", it allows the request.
|
// If the endpoint is not configured, or mapped to "*", it allows the request.
|
||||||
func RequireEndpointPermission(authService *auth.AuthService, dbConn *gorm.DB) fiber.Handler {
|
func RequireEndpointPermission(dbConn *gorm.DB, tokenService *tokens.TockenService) fiber.Handler {
|
||||||
return func(c fiber.Ctx) error {
|
return func(c fiber.Ctx) error {
|
||||||
fmt.Printf("Checking permissions for %s%s\n", strings.TrimSpace(c.Method()), strings.TrimSpace(c.Path()))
|
fmt.Printf("Checking permissions for %s%s\n", strings.TrimSpace(c.Method()), strings.TrimSpace(c.Path()))
|
||||||
perm := Endpoints[strings.TrimSpace(c.Method())+strings.TrimSpace(c.Path())]
|
perm := Endpoints[strings.TrimSpace(c.Method())+strings.TrimSpace(c.Path())]
|
||||||
|
|
@ -61,22 +59,14 @@ func RequireEndpointPermission(authService *auth.AuthService, dbConn *gorm.DB) f
|
||||||
return fiber.NewError(fiber.StatusUnauthorized, "missing token header")
|
return fiber.NewError(fiber.StatusUnauthorized, "missing token header")
|
||||||
}
|
}
|
||||||
|
|
||||||
claims, err := authService.ValidateAccessToken(tokenString)
|
claims, err := tokenService.ValidateAccessToken(tokenString)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fiber.NewError(fiber.StatusUnauthorized, err.Error())
|
return fiber.NewError(fiber.StatusUnauthorized, err.Error())
|
||||||
}
|
}
|
||||||
c.Locals("authClaims", claims)
|
c.Locals("authClaims", claims)
|
||||||
|
|
||||||
var user models.User
|
|
||||||
if err := dbConn.Select("roles").Where("email = ?", claims.Username).First(&user).Error; err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return fiber.NewError(fiber.StatusUnauthorized, "user not found")
|
|
||||||
}
|
|
||||||
return fiber.NewError(fiber.StatusInternalServerError, "failed to load user roles")
|
|
||||||
}
|
|
||||||
|
|
||||||
// user need to have at least one role that satisfies the permission requirement
|
// user need to have at least one role that satisfies the permission requirement
|
||||||
if user.Roles == nil {
|
if claims.Role == "" {
|
||||||
return fiber.NewError(fiber.StatusForbidden, "insufficient permissions")
|
return fiber.NewError(fiber.StatusForbidden, "insufficient permissions")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2,28 +2,14 @@ package routes
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"server/internal/admin"
|
"server/internal/admin"
|
||||||
"server/internal/auth"
|
|
||||||
"server/internal/mail"
|
|
||||||
"server/internal/systemUtils"
|
"server/internal/systemUtils"
|
||||||
"server/internal/user"
|
users "server/internal/user"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v3"
|
"github.com/gofiber/fiber/v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Typescript: interface
|
func Register(app *fiber.App) {
|
||||||
type FormRequest struct {
|
|
||||||
Req string `json:"req"`
|
|
||||||
Count int `json:"count"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Typescript: interface
|
|
||||||
type FormResponse struct {
|
|
||||||
Test string `json:"test"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func Register(app *fiber.App, authService *auth.AuthService, mailService *mail.Service) {
|
|
||||||
systemUtils.RegisterSystemRoutes(app)
|
systemUtils.RegisterSystemRoutes(app)
|
||||||
auth.Register(app, authService, mailService)
|
users.RegisterUserRoutes(app)
|
||||||
user.RegisterUserRoutes(app, authService)
|
|
||||||
admin.RegisterAdminRoutes(app)
|
admin.RegisterAdminRoutes(app)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,14 @@ import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/brianvoe/gofakeit/v6"
|
"github.com/brianvoe/gofakeit/v6"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"server/internal/auth"
|
"server/internal/systemUtils"
|
||||||
"server/internal/models"
|
users "server/internal/user"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Credential exposes the plaintext password generated for a seeded user.
|
// Credential exposes the plaintext password generated for a seeded user.
|
||||||
|
|
@ -20,14 +21,14 @@ type Credential struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// SeedUsers generates n fake users and persists them. Returns the created slice.
|
// SeedUsers generates n fake users and persists them. Returns the created slice.
|
||||||
func SeedUsers(db *gorm.DB, n int) ([]models.User, []Credential, error) {
|
func SeedUsers(db *gorm.DB, n int) ([]users.User, []Credential, error) {
|
||||||
if n <= 0 {
|
if n <= 0 {
|
||||||
return nil, nil, fmt.Errorf("seed size must be greater than zero")
|
return nil, nil, fmt.Errorf("seed size must be greater than zero")
|
||||||
}
|
}
|
||||||
|
|
||||||
gofakeit.Seed(time.Now().UnixNano())
|
gofakeit.Seed(time.Now().UnixNano())
|
||||||
|
|
||||||
items := make([]models.User, 0, n)
|
items := make([]users.User, 0, n)
|
||||||
creds := make([]Credential, 0, n)
|
creds := make([]Credential, 0, n)
|
||||||
for i := 0; i < n; i++ {
|
for i := 0; i < n; i++ {
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
|
|
@ -38,20 +39,20 @@ func SeedUsers(db *gorm.DB, n int) ([]models.User, []Credential, error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("generate password: %w", err)
|
return nil, nil, fmt.Errorf("generate password: %w", err)
|
||||||
}
|
}
|
||||||
passwordHash, err := auth.HashPassword(pw)
|
passwordHash, err := systemUtils.HashPassword(pw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("hash seed password: %w", err)
|
return nil, nil, fmt.Errorf("hash seed password: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
item := models.User{
|
item := users.User{
|
||||||
Email: email,
|
Email: email,
|
||||||
Name: gofakeit.Name(),
|
Name: gofakeit.Name(),
|
||||||
Password: passwordHash,
|
Password: passwordHash,
|
||||||
Roles: models.UserRoles{"user"},
|
Roles: users.UserRoles{"user"},
|
||||||
Status: models.UserStatusActive,
|
Status: users.UserStatusActive,
|
||||||
Types: models.UserTypes{"internal"},
|
Types: users.UserTypes{"internal"},
|
||||||
UUID: uuid,
|
UUID: uuid,
|
||||||
Details: &models.UserDetails{
|
Details: &users.UserDetails{
|
||||||
Title: gofakeit.JobTitle(),
|
Title: gofakeit.JobTitle(),
|
||||||
FirstName: gofakeit.FirstName(),
|
FirstName: gofakeit.FirstName(),
|
||||||
LastName: gofakeit.LastName(),
|
LastName: gofakeit.LastName(),
|
||||||
|
|
@ -61,7 +62,7 @@ func SeedUsers(db *gorm.DB, n int) ([]models.User, []Credential, error) {
|
||||||
ZipCode: gofakeit.Zip(),
|
ZipCode: gofakeit.Zip(),
|
||||||
Country: gofakeit.Country(),
|
Country: gofakeit.Country(),
|
||||||
},
|
},
|
||||||
Preferences: &models.UserPreferences{
|
Preferences: &users.UserPreferences{
|
||||||
UseIdle: gofakeit.Bool(),
|
UseIdle: gofakeit.Bool(),
|
||||||
IdleTimeout: gofakeit.Number(1, 30),
|
IdleTimeout: gofakeit.Number(1, 30),
|
||||||
UseIdlePassword: gofakeit.Bool(),
|
UseIdlePassword: gofakeit.Bool(),
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package auth
|
package systemUtils
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
package systemUtils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gofiber/fiber/v3"
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Claims struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
TokenType string `json:"type"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
func ClaimsFromCtx(c fiber.Ctx) (*Claims, bool) {
|
||||||
|
val := c.Locals("authClaims")
|
||||||
|
if val == nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
claims, ok := val.(*Claims)
|
||||||
|
return claims, ok
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,78 @@
|
||||||
package tokens
|
package tokens
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"server/internal/config"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v3"
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type TockenService struct {
|
||||||
|
cfg config.AuthConfig
|
||||||
|
secret []byte
|
||||||
|
accessExpiry time.Duration
|
||||||
|
refreshExpiry time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
type Claims struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
TokenType string `json:"type"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
// Typescript: interface
|
||||||
|
type TokenPair struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
TokenTypeAccess = "access"
|
||||||
|
TokenTypeRefresh = "refresh"
|
||||||
|
)
|
||||||
|
|
||||||
|
var Tockens *TockenService
|
||||||
|
|
||||||
|
func GetTockenService() (*TockenService, error) {
|
||||||
|
if Tockens == nil {
|
||||||
|
cfg, err := config.GetConfig()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
Tockens, err = NewTockenService(cfg.Auth)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Tockens, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTockenService(cfg config.AuthConfig) (*TockenService, error) {
|
||||||
|
if cfg.Secret == "" {
|
||||||
|
return nil, errors.New("jwt secret is required")
|
||||||
|
}
|
||||||
|
if cfg.AccessTokenExpiryMinutes <= 0 {
|
||||||
|
return nil, errors.New("access token expiry must be positive")
|
||||||
|
}
|
||||||
|
if cfg.RefreshTokenExpiryMinutes <= 0 {
|
||||||
|
return nil, errors.New("refresh token expiry must be positive")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &TockenService{
|
||||||
|
cfg: cfg,
|
||||||
|
secret: []byte(cfg.Secret),
|
||||||
|
accessExpiry: time.Duration(cfg.AccessTokenExpiryMinutes) * time.Minute,
|
||||||
|
refreshExpiry: time.Duration(cfg.RefreshTokenExpiryMinutes) * time.Minute,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func hashToken(token string) string {
|
func hashToken(token string) string {
|
||||||
sum := sha256.Sum256([]byte(token))
|
sum := sha256.Sum256([]byte(token))
|
||||||
return hex.EncodeToString(sum[:])
|
return hex.EncodeToString(sum[:])
|
||||||
|
|
@ -13,3 +81,131 @@ func hashToken(token string) string {
|
||||||
func HashToken(token string) string {
|
func HashToken(token string) string {
|
||||||
return hashToken(token)
|
return hashToken(token)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *TockenService) GenerateTokenPair(username string) (TokenPair, error) {
|
||||||
|
access, err := s.GenerateToken(username, TokenTypeAccess, s.accessExpiry)
|
||||||
|
if err != nil {
|
||||||
|
return TokenPair{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh, err := s.GenerateToken(username, TokenTypeRefresh, s.refreshExpiry)
|
||||||
|
if err != nil {
|
||||||
|
return TokenPair{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return TokenPair{
|
||||||
|
AccessToken: access,
|
||||||
|
RefreshToken: refresh,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TockenService) AccessExpiry() time.Duration {
|
||||||
|
return s.accessExpiry
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TockenService) RefreshExpiry() time.Duration {
|
||||||
|
return s.refreshExpiry
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TockenService) Middleware() fiber.Handler {
|
||||||
|
return func(c fiber.Ctx) error {
|
||||||
|
tokenString := c.Get("Auth-Token")
|
||||||
|
if tokenString == "" {
|
||||||
|
return fiber.NewError(fiber.StatusUnauthorized, "missing token header")
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, err := s.ParseToken(tokenString)
|
||||||
|
if err != nil {
|
||||||
|
return fiber.NewError(fiber.StatusUnauthorized, err.Error())
|
||||||
|
}
|
||||||
|
if claims.TokenType != TokenTypeAccess {
|
||||||
|
return fiber.NewError(fiber.StatusUnauthorized, "access token required")
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Locals("authClaims", claims)
|
||||||
|
return c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TockenService) Refresh(refreshToken string) (TokenPair, error) {
|
||||||
|
claims, err := s.ParseToken(refreshToken)
|
||||||
|
if err != nil {
|
||||||
|
return TokenPair{}, err
|
||||||
|
}
|
||||||
|
if claims.TokenType != TokenTypeRefresh {
|
||||||
|
return TokenPair{}, errors.New("refresh token required")
|
||||||
|
}
|
||||||
|
return s.GenerateTokenPair(claims.Username)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TockenService) ValidateToken(tokenString string) (*Claims, error) {
|
||||||
|
claims, err := s.ParseToken(tokenString)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if claims.TokenType != TokenTypeAccess {
|
||||||
|
return nil, errors.New("access token required")
|
||||||
|
}
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ClaimsFromCtx(c fiber.Ctx) (*Claims, bool) {
|
||||||
|
val := c.Locals("authClaims")
|
||||||
|
if val == nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
claims, ok := val.(*Claims)
|
||||||
|
return claims, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TockenService) ParseToken(tokenString string) (*Claims, error) {
|
||||||
|
claims := &Claims{}
|
||||||
|
token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) {
|
||||||
|
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
|
return nil, fiber.ErrUnauthorized
|
||||||
|
}
|
||||||
|
return s.secret, nil
|
||||||
|
})
|
||||||
|
if err != nil || !token.Valid {
|
||||||
|
return nil, errors.New("invalid or expired token")
|
||||||
|
}
|
||||||
|
if s.cfg.Issuer != "" && claims.Issuer != "" && claims.Issuer != s.cfg.Issuer {
|
||||||
|
return nil, errors.New("invalid token issuer")
|
||||||
|
}
|
||||||
|
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TockenService) GenerateToken(username, tokenType string, expiry time.Duration) (string, error) {
|
||||||
|
claims := Claims{
|
||||||
|
Username: username,
|
||||||
|
TokenType: tokenType,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
Issuer: s.cfg.Issuer,
|
||||||
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(expiry)),
|
||||||
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
return token.SignedString(s.secret)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TockenService) ValidateAccessToken(tokenString string) (*Claims, error) {
|
||||||
|
claims, err := s.ParseToken(tokenString)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if claims.TokenType != TokenTypeAccess {
|
||||||
|
return nil, errors.New("access token required")
|
||||||
|
}
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TockenService) GenerateSecureToken() (string, error) {
|
||||||
|
buf := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return base64.RawURLEncoding.EncodeToString(buf), nil
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,6 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
|
||||||
"server/internal/config"
|
|
||||||
tsrpc "server/pkg/ts-rpc"
|
tsrpc "server/pkg/ts-rpc"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -32,14 +30,6 @@ func TsGenerate() (string, error) {
|
||||||
return "", fmt.Errorf("write local generated typescript: %w", err)
|
return "", fmt.Errorf("write local generated typescript: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
configPath := os.Getenv("CONFIG_PATH")
|
|
||||||
if configPath == "" {
|
|
||||||
configPath = "configs/config.json"
|
|
||||||
}
|
|
||||||
if _, err := config.LoadConfig(configPath); err != nil {
|
|
||||||
return "", fmt.Errorf("load config from %s: %w", configPath, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
frontendAPIPath := os.Getenv("FRONTEND_API_PATH")
|
frontendAPIPath := os.Getenv("FRONTEND_API_PATH")
|
||||||
if frontendAPIPath == "" {
|
if frontendAPIPath == "" {
|
||||||
return "", errors.New("FRONTEND_API_PATH must be set")
|
return "", errors.New("FRONTEND_API_PATH must be set")
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,34 @@
|
||||||
package user
|
package users
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v3"
|
"github.com/gofiber/fiber/v3"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"server/internal/auth"
|
"server/internal/db"
|
||||||
"server/internal/helpers"
|
"server/internal/mail"
|
||||||
"server/internal/models"
|
|
||||||
"server/internal/responses"
|
"server/internal/responses"
|
||||||
|
"server/internal/systemUtils"
|
||||||
|
"server/internal/tokens"
|
||||||
|
|
||||||
"server/internal/validation"
|
"server/internal/validation"
|
||||||
)
|
)
|
||||||
|
|
||||||
type UserController struct{}
|
type UserController struct {
|
||||||
|
TockenService *tokens.TockenService
|
||||||
|
}
|
||||||
|
|
||||||
func NewUserController() *UserController {
|
func NewUserController(tockenService *tokens.TockenService) *UserController {
|
||||||
return &UserController{}
|
return &UserController{
|
||||||
|
TockenService: tockenService,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Typescript: interface
|
// Typescript: interface
|
||||||
|
|
@ -27,12 +36,12 @@ type UpdateUserRequest struct {
|
||||||
Name string `json:"name" validate:"required,min=1,max=255"`
|
Name string `json:"name" validate:"required,min=1,max=255"`
|
||||||
Email string `json:"email" validate:"required,email"`
|
Email string `json:"email" validate:"required,email"`
|
||||||
Password string `json:"password" validate:"omitempty,min=8,max=128"`
|
Password string `json:"password" validate:"omitempty,min=8,max=128"`
|
||||||
Roles models.UserRoles `json:"roles"`
|
Roles UserRoles `json:"roles"`
|
||||||
Status models.UserStatus `json:"status"`
|
Status UserStatus `json:"status"`
|
||||||
Types models.UserTypes `json:"types"`
|
Types UserTypes `json:"types"`
|
||||||
Avatar *string `json:"avatar"`
|
Avatar *string `json:"avatar"`
|
||||||
Details *models.UserDetailsShort `json:"details"`
|
Details *UserDetails `json:"details"`
|
||||||
Preferences *models.UserPreferencesShort `json:"preferences"`
|
Preferences *UserPreferences `json:"preferences"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetUser returns a single user by UUID.
|
// GetUser returns a single user by UUID.
|
||||||
|
|
@ -41,12 +50,12 @@ func (uc *UserController) GetUser(c fiber.Ctx) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return c.JSON(responses.Success(models.ToUserProfile(user)))
|
return c.JSON(responses.Success(ToUserProfile(user)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateUser creates a user together with optional details and preferences.
|
// CreateUser creates a user together with optional details and preferences.
|
||||||
func (uc *UserController) CreateUser(c fiber.Ctx) error {
|
func (uc *UserController) CreateUser(c fiber.Ctx) error {
|
||||||
var req models.UserCreateInput
|
var req UserCreateInput
|
||||||
if err := c.Bind().Body(&req); err != nil {
|
if err := c.Bind().Body(&req); err != nil {
|
||||||
return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
|
return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
|
||||||
}
|
}
|
||||||
|
|
@ -54,50 +63,50 @@ func (uc *UserController) CreateUser(c fiber.Ctx) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
db, err := helpers.DBFromCtx(c)
|
db, err := db.DBFromCtx(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
var existing models.User
|
var existing User
|
||||||
if err := db.Where("email = ?", req.Email).First(&existing).Error; err == nil {
|
if err := db.Where("email = ?", req.Email).First(&existing).Error; err == nil {
|
||||||
return fiber.NewError(fiber.StatusConflict, "user already exists")
|
return fiber.NewError(fiber.StatusConflict, "user already exists")
|
||||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return fiber.NewError(fiber.StatusInternalServerError, "failed to check user")
|
return fiber.NewError(fiber.StatusInternalServerError, "failed to check user")
|
||||||
}
|
}
|
||||||
|
|
||||||
hashedPassword, err := auth.HashPassword(req.Password)
|
hashedPassword, err := systemUtils.HashPassword(req.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fiber.NewError(fiber.StatusInternalServerError, "failed to secure password")
|
return fiber.NewError(fiber.StatusInternalServerError, "failed to secure password")
|
||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
user := models.User{
|
user := User{
|
||||||
Email: req.Email,
|
Email: req.Email,
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
Password: hashedPassword,
|
Password: hashedPassword,
|
||||||
Roles: func() models.UserRoles {
|
Roles: func() UserRoles {
|
||||||
if len(req.Roles) == 0 {
|
if len(req.Roles) == 0 {
|
||||||
return models.UserRoles{"user"}
|
return UserRoles{"user"}
|
||||||
}
|
}
|
||||||
return req.Roles
|
return req.Roles
|
||||||
}(),
|
}(),
|
||||||
Status: func() models.UserStatus {
|
Status: func() UserStatus {
|
||||||
if req.Status == "" {
|
if req.Status == "" {
|
||||||
return models.UserStatusPending
|
return UserStatusPending
|
||||||
}
|
}
|
||||||
return req.Status
|
return req.Status
|
||||||
}(),
|
}(),
|
||||||
Types: func() models.UserTypes {
|
Types: func() UserTypes {
|
||||||
if len(req.Types) == 0 {
|
if len(req.Types) == 0 {
|
||||||
return models.UserTypes{"internal"}
|
return UserTypes{"internal"}
|
||||||
}
|
}
|
||||||
return req.Types
|
return req.Types
|
||||||
}(),
|
}(),
|
||||||
Avatar: req.Avatar,
|
Avatar: req.Avatar,
|
||||||
UUID: uuid.NewString(),
|
UUID: uuid.NewString(),
|
||||||
Details: helpers.ToUserDetails(req.Details),
|
Details: req.Details,
|
||||||
Preferences: helpers.ToUserPreferences(req.Preferences),
|
Preferences: req.Preferences,
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
UpdatedAt: now,
|
UpdatedAt: now,
|
||||||
}
|
}
|
||||||
|
|
@ -110,7 +119,7 @@ func (uc *UserController) CreateUser(c fiber.Ctx) error {
|
||||||
return fiber.NewError(fiber.StatusInternalServerError, "failed to reload user")
|
return fiber.NewError(fiber.StatusInternalServerError, "failed to reload user")
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.Status(fiber.StatusCreated).JSON(responses.Success(models.ToUserProfile(&user)))
|
return c.Status(fiber.StatusCreated).JSON(responses.Success(ToUserProfile(&user)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateUser replaces user fields and synchronizes details/preferences.
|
// UpdateUser replaces user fields and synchronizes details/preferences.
|
||||||
|
|
@ -123,7 +132,7 @@ func (uc *UserController) UpdateUser(c fiber.Ctx) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
db, err := helpers.DBFromCtx(c)
|
db, err := db.DBFromCtx(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -134,7 +143,7 @@ func (uc *UserController) UpdateUser(c fiber.Ctx) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Email != user.Email {
|
if req.Email != user.Email {
|
||||||
var existing models.User
|
var existing User
|
||||||
if err := db.Select("id").Where("email = ?", req.Email).First(&existing).Error; err == nil && existing.ID != user.ID {
|
if err := db.Select("id").Where("email = ?", req.Email).First(&existing).Error; err == nil && existing.ID != user.ID {
|
||||||
return fiber.NewError(fiber.StatusConflict, "user already exists")
|
return fiber.NewError(fiber.StatusConflict, "user already exists")
|
||||||
} else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
} else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
|
@ -176,12 +185,12 @@ func (uc *UserController) UpdateUser(c fiber.Ctx) error {
|
||||||
return fiber.NewError(fiber.StatusInternalServerError, "failed to reload user")
|
return fiber.NewError(fiber.StatusInternalServerError, "failed to reload user")
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(responses.Success(models.ToUserProfile(user)))
|
return c.JSON(responses.Success(ToUserProfile(user)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteUser removes a user and linked details/preferences through cascading delete rules.
|
// DeleteUser removes a user and linked details/preferences through cascading delete rules.
|
||||||
func (uc *UserController) DeleteUser(c fiber.Ctx) error {
|
func (uc *UserController) DeleteUser(c fiber.Ctx) error {
|
||||||
db, err := helpers.DBFromCtx(c)
|
db, err := db.DBFromCtx(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -192,10 +201,10 @@ func (uc *UserController) DeleteUser(c fiber.Ctx) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := db.Transaction(func(tx *gorm.DB) error {
|
if err := db.Transaction(func(tx *gorm.DB) error {
|
||||||
if err := tx.Where("user_id = ?", user.ID).Delete(&models.UserDetails{}).Error; err != nil {
|
if err := tx.Where("user_id = ?", user.ID).Delete(&UserDetails{}).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := tx.Where("user_id = ?", user.ID).Delete(&models.UserPreferences{}).Error; err != nil {
|
if err := tx.Where("user_id = ?", user.ID).Delete(&UserPreferences{}).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return tx.Delete(user).Error
|
return tx.Delete(user).Error
|
||||||
|
|
@ -206,18 +215,348 @@ func (uc *UserController) DeleteUser(c fiber.Ctx) error {
|
||||||
return c.JSON(responses.Success(responses.SimpleResponse{Message: "user deleted"}))
|
return c.JSON(responses.Success(responses.SimpleResponse{Message: "user deleted"}))
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadUserByID(c fiber.Ctx) (*models.User, error) {
|
// Login authenticates a user and issues an access/refresh token pair.
|
||||||
|
func (uc *UserController) Login(c fiber.Ctx) error {
|
||||||
|
var req LoginRequest
|
||||||
|
if err := c.Bind().Body(&req); err != nil {
|
||||||
|
return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
|
||||||
|
}
|
||||||
|
if err := validation.ValidateStruct(&req); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := db.DBFromCtx(c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var user User
|
||||||
|
if err := db.Where("email = ?", req.Username).First(&user).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return fiber.NewError(fiber.StatusUnauthorized, "invalid credentials")
|
||||||
|
}
|
||||||
|
return fiber.NewError(fiber.StatusInternalServerError, "failed to fetch user")
|
||||||
|
}
|
||||||
|
match, err := systemUtils.VerifyPassword(user.Password, req.Password)
|
||||||
|
if err != nil {
|
||||||
|
return fiber.NewError(fiber.StatusInternalServerError, "failed to verify credentials")
|
||||||
|
}
|
||||||
|
if !match {
|
||||||
|
return fiber.NewError(fiber.StatusUnauthorized, "invalid credentials")
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := uc.TockenService.GenerateTokenPair(user.Email)
|
||||||
|
if err != nil {
|
||||||
|
return fiber.NewError(fiber.StatusInternalServerError, "failed to issue token")
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := user.ID
|
||||||
|
now := time.Now().UTC()
|
||||||
|
if err := db.Where("expires_at < ?", now).Delete(&Session{}).Error; err != nil {
|
||||||
|
return fiber.NewError(fiber.StatusInternalServerError, "failed to purge expired sessions")
|
||||||
|
}
|
||||||
|
|
||||||
|
session := Session{
|
||||||
|
UserID: &userID,
|
||||||
|
Username: user.Email,
|
||||||
|
AccessTokenHash: tokens.HashToken(token.AccessToken),
|
||||||
|
RefreshTokenHash: tokens.HashToken(token.RefreshToken),
|
||||||
|
ExpiresAt: now.Add(uc.TockenService.RefreshExpiry()),
|
||||||
|
IPAddress: c.IP(),
|
||||||
|
UserAgent: c.Get("User-Agent"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.Create(&session).Error; err != nil {
|
||||||
|
return fiber.NewError(fiber.StatusInternalServerError, "failed to record session")
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Response().Header.Set("Auth-Token", token.AccessToken)
|
||||||
|
|
||||||
|
return c.JSON(responses.Success(token))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register creates a new user with optional roles/types/preferences.
|
||||||
|
func (uc *UserController) Register(c fiber.Ctx) error {
|
||||||
|
var req UserCreateInput
|
||||||
|
if err := c.Bind().Body(&req); err != nil {
|
||||||
|
return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
|
||||||
|
}
|
||||||
|
if err := validation.ValidateStruct(&req); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := db.DBFromCtx(c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var existing User
|
||||||
|
if err := db.Where("email = ?", req.Email).First(&existing).Error; err == nil {
|
||||||
|
return fiber.NewError(fiber.StatusConflict, "user already exists")
|
||||||
|
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return fiber.NewError(fiber.StatusInternalServerError, "failed to check user")
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
hashedPassword, err := systemUtils.HashPassword(req.Password)
|
||||||
|
if err != nil {
|
||||||
|
return fiber.NewError(fiber.StatusInternalServerError, "failed to secure password")
|
||||||
|
}
|
||||||
|
user := User{
|
||||||
|
Email: req.Email,
|
||||||
|
Name: req.Name,
|
||||||
|
Password: hashedPassword,
|
||||||
|
Roles: func() UserRoles {
|
||||||
|
if len(req.Roles) == 0 {
|
||||||
|
return UserRoles{"user"}
|
||||||
|
}
|
||||||
|
return req.Roles
|
||||||
|
}(),
|
||||||
|
Status: func() UserStatus {
|
||||||
|
if req.Status == "" {
|
||||||
|
return UserStatusPending
|
||||||
|
}
|
||||||
|
return req.Status
|
||||||
|
}(),
|
||||||
|
Types: func() UserTypes {
|
||||||
|
if len(req.Types) == 0 {
|
||||||
|
return UserTypes{"internal"}
|
||||||
|
}
|
||||||
|
return req.Types
|
||||||
|
}(),
|
||||||
|
Avatar: req.Avatar,
|
||||||
|
UUID: uuid.NewString(),
|
||||||
|
Details: req.Details,
|
||||||
|
Preferences: func() *UserPreferences {
|
||||||
|
if req.Preferences == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &UserPreferences{
|
||||||
|
UseIdle: req.Preferences.UseIdle,
|
||||||
|
IdleTimeout: req.Preferences.IdleTimeout,
|
||||||
|
UseIdlePassword: req.Preferences.UseIdlePassword,
|
||||||
|
IdlePin: req.Preferences.IdlePin,
|
||||||
|
UseDirectLogin: req.Preferences.UseDirectLogin,
|
||||||
|
UseQuadcodeLogin: req.Preferences.UseQuadcodeLogin,
|
||||||
|
SendNoticesMail: req.Preferences.SendNoticesMail,
|
||||||
|
Language: req.Preferences.Language,
|
||||||
|
}
|
||||||
|
}(),
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.Create(&user).Error; err != nil {
|
||||||
|
return fiber.NewError(fiber.StatusInternalServerError, "failed to create user")
|
||||||
|
}
|
||||||
|
|
||||||
|
mailService, err := mail.New()
|
||||||
|
if err != nil {
|
||||||
|
return fiber.NewError(fiber.StatusInternalServerError, "failed to initialize mail service")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := mailService.Send(c, mail.Message{
|
||||||
|
To: user.Email,
|
||||||
|
Subject: fmt.Sprintf("[%s] Registrazione completata", mailService.AppName()),
|
||||||
|
Template: "registration",
|
||||||
|
TemplateData: mail.TemplateData{
|
||||||
|
AppName: mailService.AppName(),
|
||||||
|
UserName: user.Name,
|
||||||
|
UserEmail: user.Email,
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
return fiber.NewError(fiber.StatusInternalServerError, "failed to send registration email")
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Status(fiber.StatusCreated).JSON(responses.Success(&user))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (uc *UserController) ForgotPassword(c fiber.Ctx) error {
|
||||||
|
var req ForgotPasswordRequest
|
||||||
|
if err := c.Bind().Body(&req); err != nil {
|
||||||
|
return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
|
||||||
|
}
|
||||||
|
if err := validation.ValidateStruct(&req); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := db.DBFromCtx(c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var user User
|
||||||
|
if err := db.Where("email = ?", req.Email).First(&user).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return c.JSON(responses.Success(fiber.Map{"sent": true}))
|
||||||
|
}
|
||||||
|
return fiber.NewError(fiber.StatusInternalServerError, "failed to load user")
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.Status == UserStatusDisabled {
|
||||||
|
return c.JSON(responses.Success(fiber.Map{"sent": true}))
|
||||||
|
}
|
||||||
|
|
||||||
|
resetToken, err := uc.TockenService.GenerateSecureToken()
|
||||||
|
if err != nil {
|
||||||
|
return fiber.NewError(fiber.StatusInternalServerError, "failed to generate reset token")
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
record := PasswordResetToken{
|
||||||
|
UserID: user.ID,
|
||||||
|
TokenHash: tokens.HashToken(resetToken),
|
||||||
|
ExpiresAt: now.Add(30 * time.Minute),
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Where("user_id = ? OR expires_at < ? OR used_at IS NOT NULL", user.ID, now).
|
||||||
|
Delete(&PasswordResetToken{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Create(&record).Error
|
||||||
|
}); err != nil {
|
||||||
|
return fiber.NewError(fiber.StatusInternalServerError, "failed to store reset token")
|
||||||
|
}
|
||||||
|
|
||||||
|
mailService, err := mail.New()
|
||||||
|
if err != nil {
|
||||||
|
return fiber.NewError(fiber.StatusInternalServerError, "failed to initialize mail service")
|
||||||
|
}
|
||||||
|
if err := mailService.Send(c, mail.Message{
|
||||||
|
To: user.Email,
|
||||||
|
Subject: fmt.Sprintf("[%s] Recupero password", mailService.AppName()),
|
||||||
|
Template: "password_reset",
|
||||||
|
TemplateData: mail.TemplateData{
|
||||||
|
AppName: mailService.AppName(),
|
||||||
|
UserName: user.Name,
|
||||||
|
UserEmail: user.Email,
|
||||||
|
ResetToken: resetToken,
|
||||||
|
ResetURL: mailService.ResetLink(resetToken),
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
return fiber.NewError(fiber.StatusInternalServerError, "failed to send reset email")
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(responses.Success(responses.SimpleResponse{Message: "password reset email sent"}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (uc *UserController) ResetPassword(c fiber.Ctx) error {
|
||||||
|
var req ResetPasswordRequest
|
||||||
|
if err := c.Bind().Body(&req); err != nil {
|
||||||
|
return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
|
||||||
|
}
|
||||||
|
if err := validation.ValidateStruct(&req); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := db.DBFromCtx(c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
hashedPassword, err := systemUtils.HashPassword(req.Password)
|
||||||
|
if err != nil {
|
||||||
|
return fiber.NewError(fiber.StatusInternalServerError, "failed to secure password")
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
tokenHash := tokens.HashToken(req.Token)
|
||||||
|
if err := db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
var resetToken PasswordResetToken
|
||||||
|
if err := tx.Where("token_hash = ?", tokenHash).First(&resetToken).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return fiber.NewError(fiber.StatusBadRequest, "invalid or expired reset token")
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if resetToken.UsedAt != nil || now.After(resetToken.ExpiresAt) {
|
||||||
|
return fiber.NewError(fiber.StatusBadRequest, "invalid or expired reset token")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Model(&User{}).Where("id = ?", resetToken.UserID).Updates(map[string]any{
|
||||||
|
"password": hashedPassword,
|
||||||
|
"updated_at": now,
|
||||||
|
}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Model(&resetToken).Updates(map[string]any{
|
||||||
|
"used_at": now,
|
||||||
|
"updated_at": now,
|
||||||
|
}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Where("user_id = ?", resetToken.UserID).Delete(&Session{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Where("user_id = ? AND id <> ?", resetToken.UserID, resetToken.ID).Delete(&PasswordResetToken{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
var fiberErr *fiber.Error
|
||||||
|
if errors.As(err, &fiberErr) {
|
||||||
|
return fiberErr
|
||||||
|
}
|
||||||
|
return fiber.NewError(fiber.StatusInternalServerError, "failed to reset password")
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(responses.Success(responses.SimpleResponse{Message: "password reset successful"}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (uc *UserController) ValidToken(c fiber.Ctx) error {
|
||||||
|
raw := strings.TrimSpace(string(c.Body()))
|
||||||
|
if raw == "" {
|
||||||
|
return fiber.NewError(fiber.StatusBadRequest, "token is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
token := raw
|
||||||
|
if strings.HasPrefix(raw, "\"") && strings.HasSuffix(raw, "\"") {
|
||||||
|
if err := json.Unmarshal([]byte(raw), &token); err != nil {
|
||||||
|
return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
token = strings.TrimSpace(token)
|
||||||
|
if token == "" {
|
||||||
|
return fiber.NewError(fiber.StatusBadRequest, "token is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := db.DBFromCtx(c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
tokenHash := tokens.HashToken(token)
|
||||||
|
var resetToken PasswordResetToken
|
||||||
|
if err := db.Where("token_hash = ?", tokenHash).First(&resetToken).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return fiber.NewError(fiber.StatusBadRequest, "invalid or expired reset token")
|
||||||
|
}
|
||||||
|
return fiber.NewError(fiber.StatusInternalServerError, "failed to validate reset token")
|
||||||
|
}
|
||||||
|
|
||||||
|
if resetToken.UsedAt != nil || now.After(resetToken.ExpiresAt) {
|
||||||
|
return fiber.NewError(fiber.StatusBadRequest, "invalid or expired reset token")
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(responses.Success(responses.SimpleResponse{Message: "valid reset token"}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadUserByID(c fiber.Ctx) (*User, error) {
|
||||||
id, err := strconv.Atoi(c.Params("id"))
|
id, err := strconv.Atoi(c.Params("id"))
|
||||||
if err != nil || id <= 0 {
|
if err != nil || id <= 0 {
|
||||||
return nil, fiber.NewError(fiber.StatusBadRequest, "invalid user id")
|
return nil, fiber.NewError(fiber.StatusBadRequest, "invalid user id")
|
||||||
}
|
}
|
||||||
|
|
||||||
db, err := helpers.DBFromCtx(c)
|
db, err := db.DBFromCtx(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var user models.User
|
var user User
|
||||||
if err := db.Preload("Details").Preload("Preferences").First(&user, id).Error; err != nil {
|
if err := db.Preload("Details").Preload("Preferences").First(&user, id).Error; err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return nil, fiber.NewError(fiber.StatusNotFound, "user not found")
|
return nil, fiber.NewError(fiber.StatusNotFound, "user not found")
|
||||||
|
|
@ -228,18 +567,18 @@ func loadUserByID(c fiber.Ctx) (*models.User, error) {
|
||||||
return &user, nil
|
return &user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadUserByUUID(c fiber.Ctx) (*models.User, error) {
|
func loadUserByUUID(c fiber.Ctx) (*User, error) {
|
||||||
uuid := c.Params("uuid")
|
uuid := c.Params("uuid")
|
||||||
if uuid == "" {
|
if uuid == "" {
|
||||||
return nil, fiber.NewError(fiber.StatusBadRequest, "invalid user uuid")
|
return nil, fiber.NewError(fiber.StatusBadRequest, "invalid user uuid")
|
||||||
}
|
}
|
||||||
|
|
||||||
db, err := helpers.DBFromCtx(c)
|
db, err := db.DBFromCtx(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var user models.User
|
var user User
|
||||||
if err := db.Preload("Details").Preload("Preferences").First(&user, "uuid = ?", uuid).Error; err != nil {
|
if err := db.Preload("Details").Preload("Preferences").First(&user, "uuid = ?", uuid).Error; err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return nil, fiber.NewError(fiber.StatusNotFound, "user not found")
|
return nil, fiber.NewError(fiber.StatusNotFound, "user not found")
|
||||||
|
|
@ -250,15 +589,15 @@ func loadUserByUUID(c fiber.Ctx) (*models.User, error) {
|
||||||
return &user, nil
|
return &user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func syncUserDetails(tx *gorm.DB, userID int, input *models.UserDetailsShort) error {
|
func syncUserDetails(tx *gorm.DB, userID int, input *UserDetails) error {
|
||||||
if input == nil {
|
if input == nil {
|
||||||
return tx.Where("user_id = ?", userID).Delete(&models.UserDetails{}).Error
|
return tx.Where("user_id = ?", userID).Delete(&UserDetails{}).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
var details models.UserDetails
|
var details UserDetails
|
||||||
if err := tx.Where("user_id = ?", userID).First(&details).Error; err != nil {
|
if err := tx.Where("user_id = ?", userID).First(&details).Error; err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
details = models.UserDetails{UserID: userID}
|
details = UserDetails{UserID: userID}
|
||||||
} else {
|
} else {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -279,15 +618,15 @@ func syncUserDetails(tx *gorm.DB, userID int, input *models.UserDetailsShort) er
|
||||||
return tx.Save(&details).Error
|
return tx.Save(&details).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func syncUserPreferences(tx *gorm.DB, userID int, input *models.UserPreferencesShort) error {
|
func syncUserPreferences(tx *gorm.DB, userID int, input *UserPreferences) error {
|
||||||
if input == nil {
|
if input == nil {
|
||||||
return tx.Where("user_id = ?", userID).Delete(&models.UserPreferences{}).Error
|
return tx.Where("user_id = ?", userID).Delete(&UserPreferences{}).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
var preferences models.UserPreferences
|
var preferences UserPreferences
|
||||||
if err := tx.Where("user_id = ?", userID).First(&preferences).Error; err != nil {
|
if err := tx.Where("user_id = ?", userID).First(&preferences).Error; err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
preferences = models.UserPreferences{UserID: userID}
|
preferences = UserPreferences{UserID: userID}
|
||||||
} else {
|
} else {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -310,17 +649,17 @@ func syncUserPreferences(tx *gorm.DB, userID int, input *models.UserPreferencesS
|
||||||
|
|
||||||
// Me returns the authenticated user's profile (short format).
|
// Me returns the authenticated user's profile (short format).
|
||||||
func (uc *UserController) Me(c fiber.Ctx) error {
|
func (uc *UserController) Me(c fiber.Ctx) error {
|
||||||
claims, ok := auth.ClaimsFromCtx(c)
|
claims, ok := systemUtils.ClaimsFromCtx(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
return fiber.NewError(fiber.StatusUnauthorized, "missing claims")
|
return fiber.NewError(fiber.StatusUnauthorized, "missing claims")
|
||||||
}
|
}
|
||||||
|
|
||||||
db, err := helpers.DBFromCtx(c)
|
db, err := db.DBFromCtx(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
var user models.User
|
var user User
|
||||||
if err := db.Preload("Details").Preload("Preferences").Where("email = ?", claims.Username).First(&user).Error; err != nil {
|
if err := db.Preload("Details").Preload("Preferences").Where("email = ?", claims.Username).First(&user).Error; err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return fiber.NewError(fiber.StatusNotFound, "user not found")
|
return fiber.NewError(fiber.StatusNotFound, "user not found")
|
||||||
|
|
@ -328,5 +667,28 @@ func (uc *UserController) Me(c fiber.Ctx) error {
|
||||||
return fiber.NewError(fiber.StatusInternalServerError, "failed to load user")
|
return fiber.NewError(fiber.StatusInternalServerError, "failed to load user")
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(responses.Success(models.ToUserShort(&user)))
|
return c.JSON(responses.Success(&user))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (us *UserController) Refresh(c fiber.Ctx) error {
|
||||||
|
var req RefreshRequest
|
||||||
|
if err := c.Bind().Body(&req); err != nil {
|
||||||
|
return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
|
||||||
|
}
|
||||||
|
if req.RefreshToken == "" {
|
||||||
|
return fiber.NewError(fiber.StatusBadRequest, "refresh_token is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, err := us.TockenService.ParseToken(req.RefreshToken)
|
||||||
|
if err != nil {
|
||||||
|
return fiber.NewError(fiber.StatusUnauthorized, err.Error())
|
||||||
|
}
|
||||||
|
if claims.TokenType != tokens.TokenTypeRefresh {
|
||||||
|
return fiber.NewError(fiber.StatusUnauthorized, "refresh token required")
|
||||||
|
}
|
||||||
|
tokens, err := us.TockenService.GenerateTokenPair(claims.Username)
|
||||||
|
if err != nil {
|
||||||
|
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
return c.JSON(responses.Success(tokens))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package models
|
package users
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -11,9 +11,6 @@ import (
|
||||||
// Typescript: type
|
// Typescript: type
|
||||||
type UserRoles []string
|
type UserRoles []string
|
||||||
|
|
||||||
// Typescript: type
|
|
||||||
type UsersShort []UserShort
|
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID int `json:"id" gorm:"primaryKey"`
|
ID int `json:"id" gorm:"primaryKey"`
|
||||||
Email string `json:"email" gorm:"uniqueIndex;size:255"`
|
Email string `json:"email" gorm:"uniqueIndex;size:255"`
|
||||||
|
|
@ -43,44 +40,13 @@ type UserCreateInput struct {
|
||||||
Status UserStatus `json:"status"`
|
Status UserStatus `json:"status"`
|
||||||
Types UserTypes `json:"types"`
|
Types UserTypes `json:"types"`
|
||||||
Avatar *string `json:"avatar"`
|
Avatar *string `json:"avatar"`
|
||||||
Details *UserDetailsShort `json:"details" `
|
Details *UserDetails `json:"details" `
|
||||||
Preferences *UserPreferencesShort `json:"preferences" `
|
Preferences *UserPreferences `json:"preferences" `
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserTypes is stored as JSON array (e.g. ["internal","external"]).
|
// UserTypes is stored as JSON array (e.g. ["internal","external"]).
|
||||||
type UserTypes []string
|
type UserTypes []string
|
||||||
|
|
||||||
// UserShort is a lightweight representation of User without sensitive data.
|
|
||||||
|
|
||||||
// Typescript: interface
|
|
||||||
type UserShort struct {
|
|
||||||
Email string `json:"email" `
|
|
||||||
Name string `json:"name" `
|
|
||||||
Roles UserRoles `json:"roles" `
|
|
||||||
Status UserStatus `json:"status" `
|
|
||||||
UUID string `json:"uuid" `
|
|
||||||
Details *UserDetailsShort `json:"details" `
|
|
||||||
Preferences *UserPreferencesShort `json:"preferences" `
|
|
||||||
Avatar *string `json:"avatar" `
|
|
||||||
}
|
|
||||||
|
|
||||||
// ToUserShort maps a User to the lightweight view.
|
|
||||||
func ToUserShort(u *User) UserShort {
|
|
||||||
if u == nil {
|
|
||||||
return UserShort{}
|
|
||||||
}
|
|
||||||
return UserShort{
|
|
||||||
Email: u.Email,
|
|
||||||
Name: u.Name,
|
|
||||||
Roles: u.Roles,
|
|
||||||
Status: u.Status,
|
|
||||||
UUID: u.UUID,
|
|
||||||
Details: ToUserDetailsShort(u.Details),
|
|
||||||
Preferences: ToUserPreferencesShort(u.Preferences),
|
|
||||||
Avatar: u.Avatar,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// UserProfile is the safe full representation of a user returned by CRUD endpoints.
|
// UserProfile is the safe full representation of a user returned by CRUD endpoints.
|
||||||
//
|
//
|
||||||
// Typescript: interface
|
// Typescript: interface
|
||||||
|
|
@ -122,40 +88,6 @@ func ToUserProfile(u *User) UserProfile {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToUserDetailsShort maps UserDetails to the short version.
|
|
||||||
func ToUserDetailsShort(d *UserDetails) *UserDetailsShort {
|
|
||||||
if d == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &UserDetailsShort{
|
|
||||||
Title: d.Title,
|
|
||||||
FirstName: d.FirstName,
|
|
||||||
LastName: d.LastName,
|
|
||||||
Address: d.Address,
|
|
||||||
City: d.City,
|
|
||||||
ZipCode: d.ZipCode,
|
|
||||||
Country: d.Country,
|
|
||||||
Phone: d.Phone,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ToUserPreferencesShort maps UserPreferences to the short version.
|
|
||||||
func ToUserPreferencesShort(p *UserPreferences) *UserPreferencesShort {
|
|
||||||
if p == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &UserPreferencesShort{
|
|
||||||
UseIdle: p.UseIdle,
|
|
||||||
IdleTimeout: p.IdleTimeout,
|
|
||||||
UseIdlePassword: p.UseIdlePassword,
|
|
||||||
IdlePin: p.IdlePin,
|
|
||||||
UseDirectLogin: p.UseDirectLogin,
|
|
||||||
UseQuadcodeLogin: p.UseQuadcodeLogin,
|
|
||||||
SendNoticesMail: p.SendNoticesMail,
|
|
||||||
Language: p.Language,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// UserPreferences holds per-user settings stored as JSON.
|
// UserPreferences holds per-user settings stored as JSON.
|
||||||
type UserPreferences struct {
|
type UserPreferences struct {
|
||||||
ID int `json:"id" gorm:"primaryKey"`
|
ID int `json:"id" gorm:"primaryKey"`
|
||||||
|
|
@ -174,18 +106,6 @@ type UserPreferences struct {
|
||||||
|
|
||||||
// UserPreferences holds per-user settings stored as JSON.
|
// UserPreferences holds per-user settings stored as JSON.
|
||||||
|
|
||||||
// Typescript: interface
|
|
||||||
type UserPreferencesShort struct {
|
|
||||||
UseIdle bool `json:"useIdle"`
|
|
||||||
IdleTimeout int `json:"idleTimeout"`
|
|
||||||
UseIdlePassword bool `json:"useIdlePassword"`
|
|
||||||
IdlePin string `json:"idlePin"`
|
|
||||||
UseDirectLogin bool `json:"useDirectLogin"`
|
|
||||||
UseQuadcodeLogin bool `json:"useQuadcodeLogin"`
|
|
||||||
SendNoticesMail bool `json:"sendNoticesMail"`
|
|
||||||
Language string `json:"language"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// UserDetails holds optional profile data.
|
// UserDetails holds optional profile data.
|
||||||
type UserDetails struct {
|
type UserDetails struct {
|
||||||
ID int `json:"id" gorm:"primaryKey"`
|
ID int `json:"id" gorm:"primaryKey"`
|
||||||
|
|
@ -204,18 +124,6 @@ type UserDetails struct {
|
||||||
|
|
||||||
// UserDetails holds optional profile data.
|
// UserDetails holds optional profile data.
|
||||||
|
|
||||||
// Typescript: interface
|
|
||||||
type UserDetailsShort struct {
|
|
||||||
Title string `json:"title"`
|
|
||||||
FirstName string `json:"firstName"`
|
|
||||||
LastName string `json:"lastName"`
|
|
||||||
Address string `json:"address"`
|
|
||||||
City string `json:"city"`
|
|
||||||
ZipCode string `json:"zipCode"`
|
|
||||||
Country string `json:"country"`
|
|
||||||
Phone string `json:"phone"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Session tracks logins with browser metadata.
|
// Session tracks logins with browser metadata.
|
||||||
|
|
||||||
type Session struct {
|
type Session struct {
|
||||||
|
|
@ -252,3 +160,25 @@ const (
|
||||||
UserStatusActive UserStatus = "active"
|
UserStatusActive UserStatus = "active"
|
||||||
UserStatusDisabled UserStatus = "disabled"
|
UserStatusDisabled UserStatus = "disabled"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Typescript: interface
|
||||||
|
type LoginRequest struct {
|
||||||
|
Username string `json:"username" validate:"required,email"`
|
||||||
|
Password string `json:"password" validate:"required,min=8,max=128"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Typescript: interface
|
||||||
|
type RefreshRequest struct {
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Typescript: interface
|
||||||
|
type ForgotPasswordRequest struct {
|
||||||
|
Email string `json:"email" validate:"required,email"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Typescript: interface
|
||||||
|
type ResetPasswordRequest struct {
|
||||||
|
Token string `json:"token" validate:"required,min=20,max=255"`
|
||||||
|
Password string `json:"password" validate:"required,min=8,max=128"`
|
||||||
|
}
|
||||||
|
|
@ -1,28 +1,60 @@
|
||||||
package user
|
package users
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"server/internal/auth"
|
"fmt"
|
||||||
"server/internal/authorization"
|
"server/internal/roles"
|
||||||
|
"server/internal/tokens"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v3"
|
"github.com/gofiber/fiber/v3"
|
||||||
|
"github.com/gofiber/fiber/v3/middleware/limiter"
|
||||||
)
|
)
|
||||||
|
|
||||||
func RegisterUserRoutes(app *fiber.App, authService *auth.AuthService) {
|
func RegisterUserRoutes(app *fiber.App) {
|
||||||
userController := NewUserController()
|
tockenService, err := tokens.GetTockenService()
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Sprintf("token service: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
// Typescript: TSEndpoint= path=/users/:uuid; name=getUser; method=GET; response=models.UserProfile
|
authRateLimiter := limiter.New(limiter.Config{
|
||||||
app.Get("/users/:uuid", authService.Middleware(), userController.GetUser)
|
Max: 10,
|
||||||
|
Expiration: time.Minute,
|
||||||
|
LimiterMiddleware: limiter.SlidingWindow{},
|
||||||
|
})
|
||||||
|
|
||||||
// Typescript: TSEndpoint= path=/users; name=createUser; method=POST; request=models.UserCreateInput; response=models.UserProfile
|
userController := NewUserController(tockenService)
|
||||||
app.Post("/users", authService.Middleware(), userController.CreateUser)
|
|
||||||
|
|
||||||
// Typescript: TSEndpoint= path=/users/:uuid; name=updateUser; method=PUT; request=controllers.UpdateUserRequest; response=models.UserProfile
|
// Typescript: TSEndpoint= path=/users/:uuid; name=getUser; method=GET; response=users.UserProfile
|
||||||
app.Put("/users/:uuid", authService.Middleware(), userController.UpdateUser)
|
app.Get("/users/:uuid", tockenService.Middleware(), userController.GetUser)
|
||||||
|
|
||||||
// Typescript: TSEndpoint= path=/users/:uuid; name=deleteUser; method=DELETE; response=controllers.SimpleResponse
|
// Typescript: TSEndpoint= path=/users; name=createUser; method=POST; request=users.UserCreateInput; response=users.UserProfile
|
||||||
app.Delete("/users/:uuid", authService.Middleware(), userController.DeleteUser)
|
app.Post("/users", tockenService.Middleware(), userController.CreateUser)
|
||||||
|
|
||||||
// Typescript: TSEndpoint= path=/auth/me; name=me; method=GET; response=models.UserShort
|
// Typescript: TSEndpoint= path=/users/:uuid; name=updateUser; method=PUT; request=users.UpdateUserRequest; response=users.UserProfile
|
||||||
app.Get("/auth/me", authService.Middleware(), userController.Me)
|
app.Put("/users/:uuid", tockenService.Middleware(), userController.UpdateUser)
|
||||||
authorization.RegisterEndpoint("GET/auth/me", int(authorization.UserPermission))
|
|
||||||
|
// 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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue