package main
import (
"log"
"os"
"github.com/gofiber/fiber/v2"
"github.com/joho/godotenv"
"wucher/internal/app/api"
"wucher/internal/config"
"wucher/internal/repository/mysql"
"wucher/internal/repository/redis"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
_ "wucher/docs"
)
// @title Wucher API
// @version 1.0
// @description Wucher backend API (JSON:API).
// @description
// @description Auth Flow (Email/Password):
// @description 1) POST /api/v1/auth/register
// @description 2) Check email, open verify link (GET /api/v1/auth/verify-email)
// @description 3) POST /api/v1/auth/login
// @description - If TOTP enabled: response 202 with challenge_token
// @description 4) POST /api/v1/auth/totp/verify (challenge_token + code) -> cookies
// @description
// @description Auth Flow (TOTP setup):
// @description 1) POST /api/v1/auth/totp/setup -> secret + otpauth_url (enabled=false)
// @description 2) POST /api/v1/auth/totp/confirm -> enabled=true
// @description
// @description Auth Flow (Microsoft Entra SSO):
// @description 1) GET /api/v1/auth/microsoft/login -> redirect to Microsoft
// @description 2) Microsoft redirects to /api/v1/auth/microsoft/callback
// @description 3) Backend sets cookies then redirects to AUTH_SSO_SUCCESS_REDIRECT
// @BasePath /
func main() {
_ = godotenv.Load()
cfg := config.LoadMySQLConfigFromEnv()
db, err := mysql.Connect(cfg)
if err != nil {
log.Fatal(err)
}
if err := mysql.AutoMigrate(db); err != nil {
log.Fatal(err)
}
if err := api.SeedRoles(db); err != nil {
log.Fatal(err)
}
userRepo := mysql.NewUserRepository(db)
userSvc := service.NewUserService(userRepo)
roleRepo := mysql.NewRoleRepository(db)
roleSvc := service.NewRoleService(roleRepo)
authCfg := config.LoadAuthConfigFromEnv()
redisCfg := config.LoadRedisConfigFromEnv()
redisClient := redis.NewClient(redisCfg)
tokenStore := redis.NewTokenStore(redisClient)
smtpCfg := config.LoadSMTPConfigFromEnv()
mailer := service.NewSMTPSender(smtpCfg)
emailQueue := redis.NewEmailQueue(redisClient, authCfg.EmailQueueKey)
msCfg := config.LoadMicrosoftSSOConfigFromEnv()
var msSSO service.MicrosoftSSO
if msCfg.TenantID != "" && msCfg.ClientID != "" && msCfg.ClientSecret != "" && msCfg.RedirectURL != "" {
msSSO = service.NewMicrosoftSSO(msCfg)
}
authRepo := mysql.NewAuthRepository(db)
var protector service.SecretProtector
if p, err := service.NewAESGCMProtectorFromBase64(authCfg.TOTPSecretKeyB64); err == nil {
protector = p
}
authSvc := service.NewAuthService(authRepo, roleRepo, tokenStore, emailQueue, mailer, msSSO, protector, authCfg)
app := fiber.New()
api.UseDefaultMiddlewares(app)
api.RegisterRoutes(
app,
handlers.NewUserHandler(userSvc),
handlers.NewHealthHandler(),
handlers.NewAuthHandler(authSvc),
authSvc,
handlers.NewRoleHandler(roleSvc),
)
addr := ":8080"
if v := os.Getenv("HTTP_PORT"); v != "" {
addr = ":" + v
}
srv := api.NewServer(app)
log.Printf("API listening on %s", addr)
if err := srv.Start(addr); err != nil {
log.Fatal(err)
}
}
package main
import (
"context"
"log"
"os/signal"
"syscall"
"github.com/joho/godotenv"
"wucher/internal/config"
"wucher/internal/repository/redis"
"wucher/internal/service"
)
func main() {
_ = godotenv.Load()
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
authCfg := config.LoadAuthConfigFromEnv()
redisCfg := config.LoadRedisConfigFromEnv()
redisClient := redis.NewClient(redisCfg)
queue := redis.NewEmailQueue(redisClient, authCfg.EmailQueueKey)
smtpCfg := config.LoadSMTPConfigFromEnv()
mailer := service.NewSMTPSender(smtpCfg)
log.Println("worker started: email queue")
for {
select {
case <-ctx.Done():
log.Println("worker stopped")
return
default:
job, err := queue.Dequeue(ctx)
if err != nil {
log.Printf("queue error: %v", err)
continue
}
if job == nil {
continue
}
if err := mailer.Send(ctx, job.To, job.Subject, job.Body); err != nil {
log.Printf("send email failed: %v", err)
}
}
}
}
// Package docs Code generated by swaggo/swag. DO NOT EDIT
package docs
import "github.com/swaggo/swag"
const docTemplate = `{
"schemes": {{ marshal .Schemes }},
"swagger": "2.0",
"info": {
"description": "{{escape .Description}}",
"title": "{{.Title}}",
"contact": {},
"version": "{{.Version}}"
},
"host": "{{.Host}}",
"basePath": "{{.BasePath}}",
"paths": {
"/api/v1/auth/login": {
"post": {
"description": "If TOTP enabled, returns 202 + challenge_token. Then call /auth/totp/verify.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Auth"
],
"summary": "Login with email \u0026 password",
"parameters": [
{
"description": "JSON:API Login request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/dto.LoginRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/dto.UserResponse"
}
},
"202": {
"description": "Accepted",
"schema": {
"$ref": "#/definitions/dto.LoginTOTPRequiredResponse"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/dto.ErrorResponse"
}
},
"422": {
"description": "Unprocessable Entity",
"schema": {
"$ref": "#/definitions/dto.ErrorResponse"
}
}
}
}
},
"/api/v1/auth/logout": {
"post": {
"produces": [
"application/json"
],
"tags": [
"Auth"
],
"summary": "Logout (revoke refresh)",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/dto.VerifyEmailResponse"
}
}
}
}
},
"/api/v1/auth/me": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Auth"
],
"summary": "Get current user",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/dto.UserResponse"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/dto.ErrorResponse"
}
}
}
}
},
"/api/v1/auth/microsoft/callback": {
"get": {
"description": "Sets auth cookies and redirects to AUTH_SSO_SUCCESS_REDIRECT if configured.",
"produces": [
"application/json"
],
"tags": [
"Auth"
],
"summary": "Microsoft Entra SSO callback",
"parameters": [
{
"type": "string",
"description": "Authorization code",
"name": "code",
"in": "query",
"required": true
},
{
"type": "string",
"description": "State",
"name": "state",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/dto.MicrosoftCallbackResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/dto.ErrorResponse"
}
}
}
}
},
"/api/v1/auth/microsoft/login": {
"get": {
"description": "Returns the Microsoft authorization URL. Browser should redirect user to this URL.",
"produces": [
"application/json"
],
"tags": [
"Auth"
],
"summary": "Microsoft Entra SSO login",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/dto.MicrosoftLoginResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/dto.ErrorResponse"
}
}
}
}
},
"/api/v1/auth/refresh": {
"post": {
"produces": [
"application/json"
],
"tags": [
"Auth"
],
"summary": "Refresh access token",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/dto.TokenResponse"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/dto.ErrorResponse"
}
}
}
}
},
"/api/v1/auth/register": {
"post": {
"description": "Creates user (default role: admin) and sends verification link to email.\nNext: open verify link -\u003e then login.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Auth"
],
"summary": "Register user with email \u0026 password",
"parameters": [
{
"description": "JSON:API Register request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/dto.RegisterRequest"
}
}
],
"responses": {
"201": {
"description": "Created",
"schema": {
"$ref": "#/definitions/dto.UserResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/dto.ErrorResponse"
}
},
"422": {
"description": "Unprocessable Entity",
"schema": {
"$ref": "#/definitions/dto.ErrorResponse"
}
}
}
}
},
"/api/v1/auth/totp/confirm": {
"post": {
"description": "Validates initial TOTP code and enables TOTP for future logins.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Auth"
],
"summary": "Confirm TOTP setup",
"parameters": [
{
"description": "JSON:API TOTP confirm request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/dto.TOTPConfirmRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/dto.VerifyEmailResponse"
}
},
"422": {
"description": "Unprocessable Entity",
"schema": {
"$ref": "#/definitions/dto.ErrorResponse"
}
}
}
}
},
"/api/v1/auth/totp/disable": {
"post": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Auth"
],
"summary": "Disable TOTP",
"parameters": [
{
"description": "JSON:API TOTP disable request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/dto.TOTPDisableRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/dto.VerifyEmailResponse"
}
},
"422": {
"description": "Unprocessable Entity",
"schema": {
"$ref": "#/definitions/dto.ErrorResponse"
}
}
}
}
},
"/api/v1/auth/totp/setup": {
"post": {
"description": "Generates secret and otpauth URL. TOTP is NOT enabled until confirmed.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Auth"
],
"summary": "Setup TOTP",
"parameters": [
{
"description": "JSON:API TOTP setup request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/dto.TOTPSetupRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/dto.TOTPSetupResponse"
}
},
"422": {
"description": "Unprocessable Entity",
"schema": {
"$ref": "#/definitions/dto.ErrorResponse"
}
}
}
}
},
"/api/v1/auth/totp/verify": {
"post": {
"description": "Completes login when TOTP is enabled and returns auth cookies.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Auth"
],
"summary": "Verify TOTP code",
"parameters": [
{
"description": "JSON:API TOTP verify request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/dto.TOTPVerifyRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/dto.TokenResponse"
}
},
"422": {
"description": "Unprocessable Entity",
"schema": {
"$ref": "#/definitions/dto.ErrorResponse"
}
}
}
}
},
"/api/v1/auth/verify-email": {
"get": {
"description": "Confirms email verification after register.",
"produces": [
"application/json"
],
"tags": [
"Auth"
],
"summary": "Verify email",
"parameters": [
{
"type": "string",
"description": "Verification token",
"name": "token",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/dto.VerifyEmailResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/dto.ErrorResponse"
}
}
}
}
},
"/api/v1/roles": {
"get": {
"description": "JSON:API list with pagination, filtering and sorting. Sort values: name, -name, created_at, -created_at, updated_at, -updated_at.",
"produces": [
"application/json"
],
"tags": [
"Roles"
],
"summary": "List roles",
"parameters": [
{
"type": "string",
"description": "Filter by name (contains)",
"name": "filter[name]",
"in": "query"
},
{
"type": "integer",
"description": "Page number (default 1)",
"name": "page[number]",
"in": "query"
},
{
"type": "integer",
"description": "Page size (default 20, max 100)",
"name": "page[size]",
"in": "query"
},
{
"type": "string",
"description": "Sort order",
"name": "sort",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/dto.RoleListResponse"
}
}
}
},
"post": {
"description": "Create a new role. Name must be unique.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Roles"
],
"summary": "Create role",
"parameters": [
{
"description": "JSON:API Role create request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/dto.RoleCreateRequest"
}
}
],
"responses": {
"201": {
"description": "Created",
"schema": {
"$ref": "#/definitions/dto.RoleResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/dto.ErrorResponse"
}
},
"422": {
"description": "Unprocessable Entity",
"schema": {
"$ref": "#/definitions/dto.ErrorResponse"
}
}
}
}
},
"/api/v1/roles/datatable": {
"get": {
"description": "DataTables-style params with JSON:API response. Use draw/start/length/search[value].",
"produces": [
"application/json"
],
"tags": [
"Roles"
],
"summary": "List roles (datatable)",
"parameters": [
{
"type": "integer",
"description": "Draw counter",
"name": "draw",
"in": "query"
},
{
"type": "integer",
"description": "Offset",
"name": "start",
"in": "query"
},
{
"type": "integer",
"description": "Page size",
"name": "length",
"in": "query"
},
{
"type": "string",
"description": "Search term",
"name": "search[value]",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/dto.RoleDataTableResponse"
}
}
}
}
},
"/api/v1/roles/{id}": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Roles"
],
"summary": "Get role by ID",
"parameters": [
{
"type": "string",
"description": "Role ID (UUIDv7)",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/dto.RoleResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/dto.ErrorResponse"
}
}
}
},
"put": {
"description": "Update role by ID. Name must be unique.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Roles"
],
"summary": "Update role",
"parameters": [
{
"type": "string",
"description": "Role ID (UUIDv7)",
"name": "id",
"in": "path",
"required": true
},
{
"description": "JSON:API Role update request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/dto.RoleUpdateRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/dto.RoleResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/dto.ErrorResponse"
}
},
"422": {
"description": "Unprocessable Entity",
"schema": {
"$ref": "#/definitions/dto.ErrorResponse"
}
}
}
},
"delete": {
"produces": [
"application/json"
],
"tags": [
"Roles"
],
"summary": "Delete role",
"parameters": [
{
"type": "string",
"description": "Role ID (UUIDv7)",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/dto.VerifyEmailResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/dto.ErrorResponse"
}
}
}
}
}
},
"definitions": {
"dto.ErrorResponse": {
"type": "object",
"properties": {
"errors": {
"type": "array",
"items": {
"type": "object",
"properties": {
"detail": {
"type": "string",
"example": "email is required"
},
"source": {
"type": "object",
"properties": {
"pointer": {
"type": "string",
"example": "/data/attributes/email"
}
}
},
"status": {
"type": "string",
"example": "422"
},
"title": {
"type": "string",
"example": "Validation error"
}
}
}
}
}
},
"dto.LoginAttributes": {
"type": "object",
"required": [
"email",
"password"
],
"properties": {
"email": {
"type": "string",
"example": "user@example.com"
},
"password": {
"type": "string",
"example": "StrongP@ssw0rd"
}
}
},
"dto.LoginData": {
"type": "object",
"required": [
"attributes",
"type"
],
"properties": {
"attributes": {
"$ref": "#/definitions/dto.LoginAttributes"
},
"type": {
"type": "string",
"example": "auth_login"
}
}
},
"dto.LoginRequest": {
"type": "object",
"required": [
"data"
],
"properties": {
"data": {
"$ref": "#/definitions/dto.LoginData"
}
}
},
"dto.LoginTOTPRequiredResponse": {
"type": "object",
"properties": {
"data": {
"type": "object",
"properties": {
"attributes": {
"type": "object",
"properties": {
"challenge_token": {
"type": "string",
"example": "kP8k2..."
},
"requires_totp": {
"type": "boolean",
"example": true
}
}
},
"type": {
"type": "string",
"example": "auth_totp_required"
}
}
}
}
},
"dto.MicrosoftCallbackResponse": {
"type": "object",
"properties": {
"data": {
"type": "object",
"properties": {
"attributes": {
"type": "object",
"properties": {
"email": {
"type": "string",
"example": "user@example.com"
},
"provider": {
"type": "string",
"example": "microsoft"
},
"provider_subject": {
"type": "string",
"example": "00000000-0000-0000-0000-000000000000"
}
}
},
"id": {
"type": "string",
"example": "018f3a5c-7a6f-7c2a-8d4a-4b5b1f6c8e9d"
},
"type": {
"type": "string",
"example": "auth_identity"
}
}
}
}
},
"dto.MicrosoftLoginResponse": {
"type": "object",
"properties": {
"data": {
"type": "object",
"properties": {
"attributes": {
"type": "object",
"properties": {
"url": {
"type": "string",
"example": "https://login.microsoftonline.com/..."
}
}
},
"type": {
"type": "string",
"example": "auth_microsoft_url"
}
}
}
}
},
"dto.RegisterAttributes": {
"type": "object",
"required": [
"email",
"first_name",
"last_name",
"password"
],
"properties": {
"email": {
"type": "string",
"example": "user@example.com"
},
"first_name": {
"type": "string",
"example": "John"
},
"last_name": {
"type": "string",
"example": "Doe"
},
"mobile_phone": {
"type": "string",
"example": "+628123456789"
},
"password": {
"type": "string",
"minLength": 8,
"example": "StrongP@ssw0rd"
}
}
},
"dto.RegisterData": {
"type": "object",
"required": [
"attributes",
"type"
],
"properties": {
"attributes": {
"$ref": "#/definitions/dto.RegisterAttributes"
},
"type": {
"type": "string",
"example": "auth_register"
}
}
},
"dto.RegisterRequest": {
"type": "object",
"required": [
"data"
],
"properties": {
"data": {
"$ref": "#/definitions/dto.RegisterData"
}
}
},
"dto.RoleAttributes": {
"type": "object",
"properties": {
"created_at": {
"type": "string",
"example": "2026-02-05T10:00:00Z"
},
"description": {
"type": "string",
"example": "Full access to all resources."
},
"name": {
"type": "string",
"example": "admin"
},
"updated_at": {
"type": "string",
"example": "2026-02-05T10:00:00Z"
}
}
},
"dto.RoleCreateAttributes": {
"type": "object",
"required": [
"name"
],
"properties": {
"description": {
"type": "string",
"example": "Full access to all resources."
},
"name": {
"type": "string",
"example": "admin"
}
}
},
"dto.RoleCreateData": {
"type": "object",
"required": [
"attributes",
"type"
],
"properties": {
"attributes": {
"$ref": "#/definitions/dto.RoleCreateAttributes"
},
"type": {
"type": "string",
"example": "role"
}
}
},
"dto.RoleCreateRequest": {
"type": "object",
"required": [
"data"
],
"properties": {
"data": {
"$ref": "#/definitions/dto.RoleCreateData"
}
}
},
"dto.RoleDataTableResponse": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "#/definitions/dto.RoleResource"
}
},
"meta": {
"type": "object",
"properties": {
"draw": {
"type": "integer",
"example": 1
},
"records_filtered": {
"type": "integer",
"example": 12
},
"records_total": {
"type": "integer",
"example": 120
}
}
}
}
},
"dto.RoleListResponse": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "#/definitions/dto.RoleResource"
}
},
"meta": {
"type": "object",
"properties": {
"page_number": {
"type": "integer",
"example": 1
},
"page_size": {
"type": "integer",
"example": 20
},
"total": {
"type": "integer",
"example": 120
}
}
}
}
},
"dto.RoleMini": {
"type": "object",
"properties": {
"description": {
"type": "string",
"example": "Full access to all resources."
},
"id": {
"type": "string",
"example": "018f3a5c-7a6f-7c2a-8d4a-4b5b1f6c8e9d"
},
"name": {
"type": "string",
"example": "admin"
}
}
},
"dto.RoleResource": {
"type": "object",
"properties": {
"attributes": {
"$ref": "#/definitions/dto.RoleAttributes"
},
"id": {
"type": "string",
"example": "018f3a5c-7a6f-7c2a-8d4a-4b5b1f6c8e9d"
},
"type": {
"type": "string",
"example": "role"
}
}
},
"dto.RoleResponse": {
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/dto.RoleResource"
}
}
},
"dto.RoleUpdateAttributes": {
"type": "object",
"required": [
"name"
],
"properties": {
"description": {
"type": "string",
"example": "Full access to all resources."
},
"name": {
"type": "string",
"example": "admin"
}
}
},
"dto.RoleUpdateData": {
"type": "object",
"required": [
"attributes",
"type"
],
"properties": {
"attributes": {
"$ref": "#/definitions/dto.RoleUpdateAttributes"
},
"type": {
"type": "string",
"example": "role"
}
}
},
"dto.RoleUpdateRequest": {
"type": "object",
"required": [
"data"
],
"properties": {
"data": {
"$ref": "#/definitions/dto.RoleUpdateData"
}
}
},
"dto.TOTPConfirmAttributes": {
"type": "object",
"required": [
"code",
"user_id"
],
"properties": {
"code": {
"type": "string",
"example": "123456"
},
"user_id": {
"type": "string",
"example": "018f3a5c-7a6f-7c2a-8d4a-4b5b1f6c8e9d"
}
}
},
"dto.TOTPConfirmData": {
"type": "object",
"required": [
"attributes",
"type"
],
"properties": {
"attributes": {
"$ref": "#/definitions/dto.TOTPConfirmAttributes"
},
"type": {
"type": "string",
"example": "auth_totp_confirm"
}
}
},
"dto.TOTPConfirmRequest": {
"type": "object",
"required": [
"data"
],
"properties": {
"data": {
"$ref": "#/definitions/dto.TOTPConfirmData"
}
}
},
"dto.TOTPDisableAttributes": {
"type": "object",
"required": [
"user_id"
],
"properties": {
"user_id": {
"type": "string",
"example": "018f3a5c-7a6f-7c2a-8d4a-4b5b1f6c8e9d"
}
}
},
"dto.TOTPDisableData": {
"type": "object",
"required": [
"attributes",
"type"
],
"properties": {
"attributes": {
"$ref": "#/definitions/dto.TOTPDisableAttributes"
},
"type": {
"type": "string",
"example": "auth_totp_disable"
}
}
},
"dto.TOTPDisableRequest": {
"type": "object",
"required": [
"data"
],
"properties": {
"data": {
"$ref": "#/definitions/dto.TOTPDisableData"
}
}
},
"dto.TOTPSetupAttributes": {
"type": "object",
"required": [
"email",
"user_id"
],
"properties": {
"email": {
"type": "string",
"example": "user@example.com"
},
"user_id": {
"type": "string",
"example": "018f3a5c-7a6f-7c2a-8d4a-4b5b1f6c8e9d"
}
}
},
"dto.TOTPSetupData": {
"type": "object",
"required": [
"attributes",
"type"
],
"properties": {
"attributes": {
"$ref": "#/definitions/dto.TOTPSetupAttributes"
},
"type": {
"type": "string",
"example": "auth_totp_setup"
}
}
},
"dto.TOTPSetupRequest": {
"type": "object",
"required": [
"data"
],
"properties": {
"data": {
"$ref": "#/definitions/dto.TOTPSetupData"
}
}
},
"dto.TOTPSetupResponse": {
"type": "object",
"properties": {
"data": {
"type": "object",
"properties": {
"attributes": {
"type": "object",
"properties": {
"issuer": {
"type": "string",
"example": "Wucher"
},
"otpauth_url": {
"type": "string",
"example": "otpauth://totp/Wucher:user@example.com?..."
},
"secret": {
"type": "string",
"example": "JBSWY3DPEHPK3PXP"
}
}
},
"type": {
"type": "string",
"example": "auth_totp_setup"
}
}
}
}
},
"dto.TOTPVerifyAttributes": {
"type": "object",
"required": [
"challenge_token",
"code"
],
"properties": {
"challenge_token": {
"type": "string",
"example": "kP8k2..."
},
"code": {
"type": "string",
"example": "123456"
}
}
},
"dto.TOTPVerifyData": {
"type": "object",
"required": [
"attributes",
"type"
],
"properties": {
"attributes": {
"$ref": "#/definitions/dto.TOTPVerifyAttributes"
},
"type": {
"type": "string",
"example": "auth_totp_verify"
}
}
},
"dto.TOTPVerifyRequest": {
"type": "object",
"required": [
"data"
],
"properties": {
"data": {
"$ref": "#/definitions/dto.TOTPVerifyData"
}
}
},
"dto.TokenResponse": {
"type": "object",
"properties": {
"data": {
"type": "object",
"properties": {
"attributes": {
"type": "object",
"properties": {
"access_expires_in": {
"type": "integer",
"example": 900
},
"refresh_expires_in": {
"type": "integer",
"example": 2592000
}
}
},
"type": {
"type": "string",
"example": "auth_tokens"
}
}
}
}
},
"dto.UserAttributes": {
"type": "object",
"properties": {
"email": {
"type": "string",
"example": "user@example.com"
},
"email_verified_at": {
"type": "string",
"example": "2026-02-05T10:00:00Z"
},
"first_name": {
"type": "string",
"example": "John"
},
"last_name": {
"type": "string",
"example": "Doe"
},
"mobile_phone": {
"type": "string",
"example": "+628123456789"
},
"role": {
"$ref": "#/definitions/dto.RoleMini"
},
"role_id": {
"type": "string",
"example": "018f3a5c-7a6f-7c2a-8d4a-4b5b1f6c8e9d"
}
}
},
"dto.UserResponse": {
"type": "object",
"properties": {
"data": {
"type": "object",
"properties": {
"attributes": {
"$ref": "#/definitions/dto.UserAttributes"
},
"id": {
"type": "string",
"example": "018f3a5c-7a6f-7c2a-8d4a-4b5b1f6c8e9d"
},
"type": {
"type": "string",
"example": "user"
}
}
}
}
},
"dto.VerifyEmailResponse": {
"type": "object",
"properties": {
"data": {
"type": "object",
"properties": {
"attributes": {
"type": "object",
"properties": {
"verified": {
"type": "boolean",
"example": true
}
}
},
"type": {
"type": "string",
"example": "auth_verify_email"
}
}
}
}
}
}
}`
// SwaggerInfo holds exported Swagger Info so clients can modify it
var SwaggerInfo = &swag.Spec{
Version: "1.0",
Host: "",
BasePath: "/",
Schemes: []string{},
Title: "Wucher API",
Description: "Wucher backend API (JSON:API).\n\nAuth Flow (Email/Password):\n1) POST /api/v1/auth/register\n2) Check email, open verify link (GET /api/v1/auth/verify-email)\n3) POST /api/v1/auth/login\n- If TOTP enabled: response 202 with challenge_token\n4) POST /api/v1/auth/totp/verify (challenge_token + code) -> cookies\n\nAuth Flow (TOTP setup):\n1) POST /api/v1/auth/totp/setup -> secret + otpauth_url (enabled=false)\n2) POST /api/v1/auth/totp/confirm -> enabled=true\n\nAuth Flow (Microsoft Entra SSO):\n1) GET /api/v1/auth/microsoft/login -> redirect to Microsoft\n2) Microsoft redirects to /api/v1/auth/microsoft/callback\n3) Backend sets cookies then redirects to AUTH_SSO_SUCCESS_REDIRECT",
InfoInstanceName: "swagger",
SwaggerTemplate: docTemplate,
LeftDelim: "{{",
RightDelim: "}}",
}
func init() {
swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo)
}
package api
import (
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/logger"
"github.com/gofiber/fiber/v2/middleware/recover"
"github.com/gofiber/fiber/v2/middleware/requestid"
)
// UseDefaultMiddlewares applies baseline best-practice middleware.
func UseDefaultMiddlewares(app *fiber.App) {
app.Use(recover.New())
app.Use(requestid.New())
app.Use(logger.New())
}
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func RegisterRoutes(
app *fiber.App,
userHandler *handlers.UserHandler,
healthHandler *handlers.HealthHandler,
authHandler *handlers.AuthHandler,
authSvc *service.AuthService,
roleHandler *handlers.RoleHandler,
) {
app.Get("/health", healthHandler.Handle)
api := app.Group("/api")
v1 := api.Group("/v1")
registerAuthRoutes(v1, authHandler, authSvc)
registerUserRoutes(v1, userHandler)
registerRoleRoutes(v1, roleHandler, middleware.AuthRequired(authSvc))
registerSwaggerRoutes(app)
}
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerAuthRoutes(v1 fiber.Router, authHandler *handlers.AuthHandler, authSvc *service.AuthService) {
auth := v1.Group("/auth")
auth.Post("/register", authHandler.Register)
auth.Post("/login", authHandler.Login)
auth.Post("/refresh", authHandler.Refresh)
auth.Post("/logout", authHandler.Logout)
auth.Get("/me", middleware.AuthRequired(authSvc), authHandler.Me)
auth.Get("/verify-email", authHandler.VerifyEmail)
auth.Get("/microsoft/login", authHandler.MicrosoftLogin)
auth.Get("/microsoft/callback", authHandler.MicrosoftCallback)
auth.Post("/totp/setup", authHandler.TOTPSetup)
auth.Post("/totp/confirm", authHandler.TOTPConfirm)
auth.Post("/totp/verify", authHandler.TOTPVerify)
auth.Post("/totp/disable", authHandler.TOTPDisable)
}
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/transport/http/handlers"
)
func registerRoleRoutes(v1 fiber.Router, roleHandler *handlers.RoleHandler, authMiddleware fiber.Handler) {
roles := v1.Group("/roles", authMiddleware)
roles.Post("/", roleHandler.Create)
roles.Get("/", roleHandler.List)
roles.Get("/datatable", roleHandler.ListDatatable)
roles.Get("/:id", roleHandler.GetByID)
roles.Put("/:id", roleHandler.Update)
roles.Delete("/:id", roleHandler.Delete)
}
package api
import (
"github.com/gofiber/fiber/v2"
"github.com/gofiber/swagger"
)
func registerSwaggerRoutes(app *fiber.App) {
app.Get("/swagger/*", swagger.HandlerDefault)
}
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/transport/http/handlers"
)
func registerUserRoutes(v1 fiber.Router, userHandler *handlers.UserHandler) {
users := v1.Group("/users")
users.Get("/", userHandler.List)
users.Get("/:id", userHandler.Get)
}
package api
import (
"gorm.io/gorm"
"gorm.io/gorm/clause"
"wucher/internal/domain/auth"
)
// SeedRoles inserts default roles if they don't exist.
func SeedRoles(db *gorm.DB) error {
roles := []auth.Role{
{Name: "admin", Description: "Full access to all resources."},
{Name: "staff", Description: "Operational access with limited admin rights."},
{Name: "user", Description: "Regular end-user access."},
}
return db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "name"}},
DoNothing: true,
}).Create(&roles).Error
}
package api
import (
"context"
"time"
"github.com/gofiber/fiber/v2"
)
type Server struct {
app *fiber.App
}
func NewServer(app *fiber.App) *Server {
return &Server{
app: app,
}
}
func (s *Server) Start(addr string) error {
return s.app.Listen(addr)
}
func (s *Server) Shutdown(ctx context.Context) error {
shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
return s.app.ShutdownWithContext(shutdownCtx)
}
package worker
type Runner struct{}
func NewRunner() *Runner {
return &Runner{}
}
func (r *Runner) Start() error {
return nil
}
package config
import (
"os"
"strconv"
"strings"
"time"
)
type MySQLConfig struct {
DSN string
MaxOpenConns int
MaxIdleConns int
ConnMaxLifetime time.Duration
ConnMaxIdleTime time.Duration
}
type AuthConfig struct {
PasswordPepper string
HashTime uint32
HashMemoryKB uint32
HashThreads uint8
HashKeyLen uint32
EmailVerifyTTL time.Duration
EmailVerifyURL string
EmailQueueKey string
TOTPChallengeTTL time.Duration
SSOStateTTL time.Duration
TOTPIssuer string
TOTPSecretKeyB64 string
JWTAccessSecret string
JWTRefreshSecret string
JWTAccessTTL time.Duration
JWTRefreshTTL time.Duration
JWTCookieDomain string
JWTCookieSecure bool
JWTCookieSameSite string
JWTAccessCookieName string
JWTRefreshCookieName string
SSOSuccessRedirectURL string
DefaultRoleName string
DisableRegister bool
}
type SMTPConfig struct {
Host string
Port int
Username string
Password string
From string
InsecureSkipVerify bool
}
type MicrosoftSSOConfig struct {
TenantID string
ClientID string
ClientSecret string
RedirectURL string
Authority string
Scopes []string
}
type RedisConfig struct {
Addr string
Password string
DB int
}
func LoadMySQLConfigFromEnv() MySQLConfig {
return MySQLConfig{
DSN: getenv("MYSQL_DSN", ""),
MaxOpenConns: getenvInt("MYSQL_MAX_OPEN_CONNS", 25),
MaxIdleConns: getenvInt("MYSQL_MAX_IDLE_CONNS", 10),
ConnMaxLifetime: getenvDuration("MYSQL_CONN_MAX_LIFETIME", 5*time.Minute),
ConnMaxIdleTime: getenvDuration("MYSQL_CONN_MAX_IDLE_TIME", 2*time.Minute),
}
}
func LoadAuthConfigFromEnv() AuthConfig {
return AuthConfig{
PasswordPepper: getenv("AUTH_PASSWORD_PEPPER", ""),
HashTime: uint32(getenvInt("AUTH_HASH_TIME", 1)),
HashMemoryKB: uint32(getenvInt("AUTH_HASH_MEMORY_KB", 64*1024)),
HashThreads: uint8(getenvInt("AUTH_HASH_THREADS", 4)),
HashKeyLen: uint32(getenvInt("AUTH_HASH_KEY_LEN", 32)),
EmailVerifyTTL: getenvDuration("AUTH_EMAIL_VERIFY_TTL", 24*time.Hour),
EmailVerifyURL: getenv("AUTH_EMAIL_VERIFY_URL", ""),
EmailQueueKey: getenv("AUTH_EMAIL_QUEUE_KEY", "queue:email"),
TOTPChallengeTTL: getenvDuration("AUTH_TOTP_CHALLENGE_TTL", 5*time.Minute),
SSOStateTTL: getenvDuration("AUTH_SSO_STATE_TTL", 10*time.Minute),
TOTPIssuer: getenv("TOTP_ISSUER", "Wucher"),
TOTPSecretKeyB64: getenv("TOTP_SECRET_KEY", ""),
JWTAccessSecret: getenv("AUTH_JWT_ACCESS_SECRET", ""),
JWTRefreshSecret: getenv("AUTH_JWT_REFRESH_SECRET", ""),
JWTAccessTTL: getenvDuration("AUTH_JWT_ACCESS_TTL", 15*time.Minute),
JWTRefreshTTL: getenvDuration("AUTH_JWT_REFRESH_TTL", 30*24*time.Hour),
JWTCookieDomain: getenv("AUTH_JWT_COOKIE_DOMAIN", ""),
JWTCookieSecure: getenv("AUTH_JWT_COOKIE_SECURE", "false") == "true",
JWTCookieSameSite: getenv("AUTH_JWT_COOKIE_SAMESITE", "Lax"),
JWTAccessCookieName: getenv("AUTH_JWT_ACCESS_COOKIE", "wucher_at"),
JWTRefreshCookieName: getenv("AUTH_JWT_REFRESH_COOKIE", "wucher_rt"),
SSOSuccessRedirectURL: getenv("AUTH_SSO_SUCCESS_REDIRECT", ""),
DefaultRoleName: getenv("AUTH_DEFAULT_ROLE", "admin"),
DisableRegister: getenv("AUTH_DISABLE_REGISTER", "false") == "true",
}
}
func LoadSMTPConfigFromEnv() SMTPConfig {
return SMTPConfig{
Host: getenv("SMTP_HOST", ""),
Port: getenvInt("SMTP_PORT", 587),
Username: getenv("SMTP_USERNAME", ""),
Password: getenv("SMTP_PASSWORD", ""),
From: getenv("SMTP_FROM", ""),
InsecureSkipVerify: getenv("SMTP_INSECURE_SKIP_VERIFY", "false") == "true",
}
}
func LoadMicrosoftSSOConfigFromEnv() MicrosoftSSOConfig {
scopes := strings.Split(getenv("MS_ENTRA_SCOPES", "openid,profile,email"), ",")
for i := range scopes {
scopes[i] = strings.TrimSpace(scopes[i])
}
return MicrosoftSSOConfig{
TenantID: getenv("MS_ENTRA_TENANT_ID", ""),
ClientID: getenv("MS_ENTRA_CLIENT_ID", ""),
ClientSecret: getenv("MS_ENTRA_CLIENT_SECRET", ""),
RedirectURL: getenv("MS_ENTRA_REDIRECT_URL", ""),
Authority: getenv("MS_ENTRA_AUTHORITY", "https://login.microsoftonline.com/"),
Scopes: scopes,
}
}
func LoadRedisConfigFromEnv() RedisConfig {
return RedisConfig{
Addr: getenv("REDIS_ADDR", ""),
Password: getenv("REDIS_PASSWORD", ""),
DB: getenvInt("REDIS_DB", 0),
}
}
func getenv(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func getenvInt(key string, def int) int {
if v := os.Getenv(key); v != "" {
if n, err := strconv.Atoi(v); err == nil {
return n
}
}
return def
}
func getenvDuration(key string, def time.Duration) time.Duration {
if v := os.Getenv(key); v != "" {
if d, err := time.ParseDuration(v); err == nil {
return d
}
}
return def
}
package auth
import (
"time"
"gorm.io/gorm"
"wucher/internal/pkg/uuidv7"
)
type UserIdentity struct {
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
UserID []byte `gorm:"type:binary(16);index;not null;column:user_id"`
Provider string `gorm:"type:varchar(32);uniqueIndex:ux_provider_subject;not null;column:provider"`
ProviderSubject string `gorm:"type:varchar(191);uniqueIndex:ux_provider_subject;not null;column:provider_subject"`
Email string `gorm:"type:varchar(191);index;column:email"`
Metadata []byte `gorm:"type:json;column:metadata"`
CreatedAt time.Time
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
UpdatedAt time.Time
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
}
func (UserIdentity) TableName() string { return "user_identities" }
func (ui *UserIdentity) BeforeCreate(tx *gorm.DB) error {
if len(ui.ID) == 0 {
ui.ID = uuidv7.MustBytes()
}
return nil
}
package auth
import (
"time"
"gorm.io/gorm"
"wucher/internal/pkg/uuidv7"
)
type Permission struct {
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
Name string `gorm:"type:varchar(64);uniqueIndex;not null;column:name"`
Key string `gorm:"type:varchar(64);uniqueIndex;not null;column:key"`
Description string `gorm:"type:varchar(255);column:description"`
CreatedAt time.Time
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
UpdatedAt time.Time
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
}
func (Permission) TableName() string { return "permissions" }
func (p *Permission) BeforeCreate(tx *gorm.DB) error {
if len(p.ID) == 0 {
p.ID = uuidv7.MustBytes()
}
return nil
}
package auth
import (
"time"
"gorm.io/gorm"
"wucher/internal/pkg/uuidv7"
)
type Role struct {
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
Name string `gorm:"type:varchar(64);uniqueIndex;not null;column:name"`
Description string `gorm:"type:varchar(255);column:description"`
CreatedAt time.Time
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
UpdatedAt time.Time
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
}
func (Role) TableName() string { return "roles" }
func (r *Role) BeforeCreate(tx *gorm.DB) error {
if len(r.ID) == 0 {
r.ID = uuidv7.MustBytes()
}
return nil
}
package auth
import (
"time"
"gorm.io/gorm"
"wucher/internal/pkg/uuidv7"
)
type RolePermission struct {
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
RoleID []byte `gorm:"type:binary(16);index;uniqueIndex:ux_role_permission;not null;column:role_id"`
PermissionID []byte `gorm:"type:binary(16);index;uniqueIndex:ux_role_permission;not null;column:permission_id"`
CreatedAt time.Time
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
UpdatedAt time.Time
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
}
func (RolePermission) TableName() string { return "role_permissions" }
func (rp *RolePermission) BeforeCreate(tx *gorm.DB) error {
if len(rp.ID) == 0 {
rp.ID = uuidv7.MustBytes()
}
return nil
}
package auth
import (
"time"
"gorm.io/gorm"
"wucher/internal/pkg/uuidv7"
)
type UserTOTP struct {
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
UserID []byte `gorm:"type:binary(16);uniqueIndex;not null;column:user_id"`
SecretEncrypted []byte `gorm:"type:varbinary(512);not null;column:secret_encrypted"`
Enabled bool `gorm:"type:tinyint(1);not null;default:1;column:enabled"`
LastUsedAt *time.Time `gorm:"column:last_used_at"`
CreatedAt time.Time
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
UpdatedAt time.Time
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
}
func (UserTOTP) TableName() string { return "user_totp" }
func (ut *UserTOTP) BeforeCreate(tx *gorm.DB) error {
if len(ut.ID) == 0 {
ut.ID = uuidv7.MustBytes()
}
return nil
}
package auth
import (
"time"
"gorm.io/gorm"
"wucher/internal/pkg/uuidv7"
)
// Note: UUIDv7 should be generated at application layer and stored as BINARY(16).
type User struct {
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
Email string `gorm:"type:varchar(191);uniqueIndex;not null;column:email"`
FirstName string `gorm:"type:varchar(100);not null;column:first_name"`
LastName string `gorm:"type:varchar(100);not null;column:last_name"`
MobilePhone string `gorm:"type:varchar(32);uniqueIndex;column:mobile_phone"`
RoleID []byte `gorm:"type:binary(16);index;not null;column:role_id"`
PasswordHash []byte `gorm:"type:varbinary(255);column:password_hash"`
SaltValue []byte `gorm:"type:varbinary(255);column:salt_value"`
EmailVerifiedAt *time.Time `gorm:"column:email_verified_at"`
CreatedAt time.Time
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
UpdatedAt time.Time
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
}
func (User) TableName() string { return "users" }
func (u *User) BeforeCreate(tx *gorm.DB) error {
if len(u.ID) == 0 {
u.ID = uuidv7.MustBytes()
}
return nil
}
package logger
import "log"
func New() *log.Logger {
return log.Default()
}
package uuidv7
import (
"crypto/rand"
"encoding/hex"
"errors"
"time"
)
// New returns a UUIDv7 (RFC 9562) as a 16-byte array.
func New() ([16]byte, error) {
var out [16]byte
var r [10]byte
if _, err := rand.Read(r[:]); err != nil {
return out, err
}
// 48-bit Unix epoch milliseconds
ms := uint64(time.Now().UnixNano() / int64(time.Millisecond))
out[0] = byte(ms >> 40)
out[1] = byte(ms >> 32)
out[2] = byte(ms >> 24)
out[3] = byte(ms >> 16)
out[4] = byte(ms >> 8)
out[5] = byte(ms)
// Version (0111) in high 4 bits of byte 6
out[6] = (r[0] & 0x0f) | 0x70
out[7] = r[1]
// Variant (10) in high 2 bits of byte 8
out[8] = (r[2] & 0x3f) | 0x80
out[9] = r[3]
out[10] = r[4]
out[11] = r[5]
out[12] = r[6]
out[13] = r[7]
out[14] = r[8]
out[15] = r[9]
return out, nil
}
// MustNew panics if UUID generation fails.
func MustNew() [16]byte {
u, err := New()
if err != nil {
panic(err)
}
return u
}
// Bytes returns a new UUIDv7 as []byte for BINARY(16) columns.
func Bytes() ([]byte, error) {
u, err := New()
if err != nil {
return nil, err
}
b := make([]byte, 16)
copy(b, u[:])
return b, nil
}
// MustBytes panics if UUID generation fails.
func MustBytes() []byte {
u := MustNew()
b := make([]byte, 16)
copy(b, u[:])
return b
}
// String formats a UUID as a canonical string (8-4-4-4-12).
func String(u [16]byte) (string, error) {
if len(u) != 16 {
return "", errors.New("invalid uuid length")
}
dst := make([]byte, 36)
hex.Encode(dst[0:8], u[0:4])
dst[8] = '-'
hex.Encode(dst[9:13], u[4:6])
dst[13] = '-'
hex.Encode(dst[14:18], u[6:8])
dst[18] = '-'
hex.Encode(dst[19:23], u[8:10])
dst[23] = '-'
hex.Encode(dst[24:36], u[10:16])
return string(dst), nil
}
// BytesToString formats a UUID stored in []byte as a canonical string.
func BytesToString(b []byte) (string, error) {
if len(b) != 16 {
return "", errors.New("invalid uuid length")
}
var u [16]byte
copy(u[:], b)
return String(u)
}
// ParseString parses a canonical UUID string into []byte (16 bytes).
func ParseString(s string) ([]byte, error) {
if len(s) != 36 {
return nil, errors.New("invalid uuid string length")
}
buf := make([]byte, 16)
_, err := hex.Decode(buf[0:4], []byte(s[0:8]))
if err != nil {
return nil, err
}
_, err = hex.Decode(buf[4:6], []byte(s[9:13]))
if err != nil {
return nil, err
}
_, err = hex.Decode(buf[6:8], []byte(s[14:18]))
if err != nil {
return nil, err
}
_, err = hex.Decode(buf[8:10], []byte(s[19:23]))
if err != nil {
return nil, err
}
_, err = hex.Decode(buf[10:16], []byte(s[24:36]))
if err != nil {
return nil, err
}
return buf, nil
}
package mysql
import (
"context"
"errors"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"wucher/internal/domain/auth"
)
type AuthRepository struct {
db *gorm.DB
}
func NewAuthRepository(db *gorm.DB) *AuthRepository {
return &AuthRepository{db: db}
}
// Users
func (r *AuthRepository) CreateUser(ctx context.Context, user *auth.User) error {
return r.db.WithContext(ctx).Create(user).Error
}
func (r *AuthRepository) GetUserByID(ctx context.Context, id []byte) (*auth.User, error) {
var user auth.User
err := r.db.WithContext(ctx).First(&user, "id = ?", id).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return &user, err
}
func (r *AuthRepository) GetUserByEmail(ctx context.Context, email string) (*auth.User, error) {
var user auth.User
err := r.db.WithContext(ctx).Where("email = ?", email).First(&user).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return &user, err
}
func (r *AuthRepository) MarkEmailVerified(ctx context.Context, id []byte) error {
now := time.Now()
return r.db.WithContext(ctx).
Model(&auth.User{}).
Where("id = ?", id).
Update("email_verified_at", &now).Error
}
// Identities (SSO)
func (r *AuthRepository) UpsertIdentity(ctx context.Context, identity *auth.UserIdentity) error {
// Ensure we keep a single row per provider+subject
return r.db.WithContext(ctx).
Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "provider"}, {Name: "provider_subject"}},
DoUpdates: clause.AssignmentColumns([]string{"user_id", "email", "metadata", "updated_at", "updated_by"}),
}).
Create(identity).Error
}
func (r *AuthRepository) GetIdentityByProviderSubject(ctx context.Context, provider, subject string) (*auth.UserIdentity, error) {
var identity auth.UserIdentity
err := r.db.WithContext(ctx).
Where("provider = ? AND provider_subject = ?", provider, subject).
First(&identity).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return &identity, err
}
// TOTP
func (r *AuthRepository) GetUserTOTP(ctx context.Context, userID []byte) (*auth.UserTOTP, error) {
var totp auth.UserTOTP
err := r.db.WithContext(ctx).Where("user_id = ?", userID).First(&totp).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return &totp, err
}
func (r *AuthRepository) UpsertUserTOTP(ctx context.Context, totp *auth.UserTOTP) error {
return r.db.WithContext(ctx).
Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "user_id"}},
DoUpdates: clause.AssignmentColumns([]string{"secret_encrypted", "enabled", "updated_at", "updated_by"}),
}).
Create(totp).Error
}
func (r *AuthRepository) DisableUserTOTP(ctx context.Context, userID []byte) error {
return r.db.WithContext(ctx).
Model(&auth.UserTOTP{}).
Where("user_id = ?", userID).
Updates(map[string]any{
"enabled": false,
"updated_at": time.Now(),
}).Error
}
func (r *AuthRepository) UpdateUserTOTPLastUsed(ctx context.Context, userID []byte) error {
return r.db.WithContext(ctx).
Model(&auth.UserTOTP{}).
Where("user_id = ? AND enabled = 1", userID).
Update("last_used_at", time.Now()).Error
}
package mysql
import (
"errors"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"wucher/internal/config"
"wucher/internal/domain/auth"
)
func Connect(cfg config.MySQLConfig) (*gorm.DB, error) {
if cfg.DSN == "" {
return nil, errors.New("MYSQL_DSN is required")
}
db, err := gorm.Open(mysql.Open(cfg.DSN), &gorm.Config{
Logger: logger.Default.LogMode(logger.Warn),
})
if err != nil {
return nil, err
}
sqlDB, err := db.DB()
if err != nil {
return nil, err
}
sqlDB.SetMaxOpenConns(cfg.MaxOpenConns)
sqlDB.SetMaxIdleConns(cfg.MaxIdleConns)
sqlDB.SetConnMaxLifetime(cfg.ConnMaxLifetime)
sqlDB.SetConnMaxIdleTime(cfg.ConnMaxIdleTime)
return db, nil
}
func AutoMigrate(db *gorm.DB) error {
return db.AutoMigrate(
&auth.Role{},
&auth.Permission{},
&auth.User{},
&auth.RolePermission{},
&auth.UserIdentity{},
&auth.UserTOTP{},
)
}
package mysql
import (
"context"
"errors"
"strings"
"gorm.io/gorm"
"wucher/internal/domain/auth"
)
type RoleRepository struct {
db *gorm.DB
}
func NewRoleRepository(db *gorm.DB) *RoleRepository {
return &RoleRepository{db: db}
}
func (r *RoleRepository) CreateRole(ctx context.Context, role *auth.Role) error {
return r.db.WithContext(ctx).Create(role).Error
}
func (r *RoleRepository) UpdateRole(ctx context.Context, role *auth.Role) error {
return r.db.WithContext(ctx).Save(role).Error
}
func (r *RoleRepository) DeleteRole(ctx context.Context, id []byte) error {
return r.db.WithContext(ctx).Delete(&auth.Role{}, "id = ?", id).Error
}
func (r *RoleRepository) GetRoleByID(ctx context.Context, id []byte) (*auth.Role, error) {
var role auth.Role
err := r.db.WithContext(ctx).First(&role, "id = ?", id).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return &role, err
}
func (r *RoleRepository) GetRoleByName(ctx context.Context, name string) (*auth.Role, error) {
var role auth.Role
err := r.db.WithContext(ctx).Where("name = ?", name).First(&role).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return &role, err
}
func (r *RoleRepository) ListRoles(ctx context.Context, filterName, sort string, limit, offset int) ([]auth.Role, int64, error) {
var roles []auth.Role
var total int64
query := r.db.WithContext(ctx).Model(&auth.Role{})
if filterName != "" {
query = query.Where("LOWER(name) LIKE ?", "%"+strings.ToLower(filterName)+"%")
}
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
if sort != "" {
query = query.Order(sort)
} else {
query = query.Order("created_at DESC")
}
if limit > 0 {
query = query.Limit(limit).Offset(offset)
}
if err := query.Find(&roles).Error; err != nil {
return nil, 0, err
}
return roles, total, nil
}
package mysql
import (
"context"
"errors"
"gorm.io/gorm"
"wucher/internal/domain/user"
)
type UserRepository struct {
db *gorm.DB
}
func NewUserRepository(db *gorm.DB) *UserRepository {
return &UserRepository{db: db}
}
func (r *UserRepository) Create(ctx context.Context, u *user.User) error {
return r.db.WithContext(ctx).Create(u).Error
}
func (r *UserRepository) GetByID(ctx context.Context, id []byte) (*user.User, error) {
var u user.User
err := r.db.WithContext(ctx).First(&u, "id = ?", id).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return &u, err
}
func (r *UserRepository) GetByEmail(ctx context.Context, email string) (*user.User, error) {
var u user.User
err := r.db.WithContext(ctx).Where("email = ?", email).First(&u).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return &u, err
}
package redis
import (
"context"
"time"
"github.com/redis/go-redis/v9"
"wucher/internal/service"
)
type EmailQueue struct {
client *redis.Client
key string
}
func NewEmailQueue(client *redis.Client, key string) *EmailQueue {
return &EmailQueue{client: client, key: key}
}
func (q *EmailQueue) Enqueue(ctx context.Context, job service.EmailJob) error {
b, err := service.EncodeEmailJob(job)
if err != nil {
return err
}
return q.client.RPush(ctx, q.key, b).Err()
}
func (q *EmailQueue) Dequeue(ctx context.Context) (*service.EmailJob, error) {
res, err := q.client.BLPop(ctx, 10*time.Second, q.key).Result()
if err == redis.Nil {
return nil, nil
}
if err != nil {
return nil, err
}
if len(res) < 2 {
return nil, nil
}
return service.DecodeEmailJob([]byte(res[1]))
}
package redis
import (
"context"
"github.com/redis/go-redis/v9"
"wucher/internal/config"
)
func NewClient(cfg config.RedisConfig) *redis.Client {
return redis.NewClient(&redis.Options{
Addr: cfg.Addr,
Password: cfg.Password,
DB: cfg.DB,
})
}
func Ping(ctx context.Context, client *redis.Client) error {
return client.Ping(ctx).Err()
}
package redis
import (
"context"
"time"
"github.com/redis/go-redis/v9"
)
type TokenStore struct {
client *redis.Client
}
func NewTokenStore(client *redis.Client) *TokenStore {
return &TokenStore{client: client}
}
func (s *TokenStore) Set(ctx context.Context, key string, value []byte, ttl time.Duration) error {
return s.client.Set(ctx, key, value, ttl).Err()
}
func (s *TokenStore) Get(ctx context.Context, key string) ([]byte, error) {
val, err := s.client.Get(ctx, key).Bytes()
if err == redis.Nil {
return nil, nil
}
return val, err
}
func (s *TokenStore) Delete(ctx context.Context, key string) error {
return s.client.Del(ctx, key).Err()
}
package service
import (
"context"
"crypto/rand"
"encoding/base64"
"errors"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/pquerna/otp"
"github.com/pquerna/otp/totp"
"golang.org/x/crypto/argon2"
"wucher/internal/config"
"wucher/internal/domain/auth"
"wucher/internal/pkg/uuidv7"
)
var (
ErrInvalidCredentials = errors.New("invalid credentials")
ErrEmailNotVerified = errors.New("email not verified")
ErrInvalidToken = errors.New("invalid or expired token")
ErrTOTPRequired = errors.New("totp required")
)
type EmailSender interface {
Send(ctx context.Context, to, subject, body string) error
}
type TokenStore interface {
Set(ctx context.Context, key string, value []byte, ttl time.Duration) error
Get(ctx context.Context, key string) ([]byte, error)
Delete(ctx context.Context, key string) error
}
type SecretProtector interface {
Encrypt(plain []byte) ([]byte, error)
Decrypt(cipher []byte) ([]byte, error)
}
type MicrosoftSSO interface {
AuthCodeURL(state string) string
ExchangeCode(ctx context.Context, code string) (MicrosoftClaims, error)
}
type MicrosoftClaims struct {
Subject string
Email string
Name string
}
type AuthService struct {
repo auth.Repository
roleRepo auth.RoleRepository
tokens TokenStore
emailQueue EmailQueue
mailer EmailSender
sso MicrosoftSSO
protector SecretProtector
cfg config.AuthConfig
}
func NewAuthService(
repo auth.Repository,
roleRepo auth.RoleRepository,
tokens TokenStore,
emailQueue EmailQueue,
mailer EmailSender,
sso MicrosoftSSO,
protector SecretProtector,
cfg config.AuthConfig,
) *AuthService {
return &AuthService{
repo: repo,
roleRepo: roleRepo,
tokens: tokens,
emailQueue: emailQueue,
mailer: mailer,
sso: sso,
protector: protector,
cfg: cfg,
}
}
func (s *AuthService) BeginMicrosoftLogin(ctx context.Context) (string, error) {
if s.sso == nil || s.tokens == nil {
return "", errors.New("sso not configured")
}
state, err := randomToken(32)
if err != nil {
return "", err
}
if err := s.tokens.Set(ctx, ssoStateKey(state), []byte("1"), s.cfg.SSOStateTTL); err != nil {
return "", err
}
return s.sso.AuthCodeURL(state), nil
}
func (s *AuthService) CompleteMicrosoftLogin(ctx context.Context, code, state string) (*auth.UserIdentity, error) {
if s.sso == nil || s.tokens == nil {
return nil, errors.New("sso not configured")
}
if state == "" {
return nil, ErrInvalidToken
}
v, err := s.tokens.Get(ctx, ssoStateKey(state))
if err != nil || v == nil {
return nil, ErrInvalidToken
}
_ = s.tokens.Delete(ctx, ssoStateKey(state))
return s.LoginWithMicrosoft(ctx, code)
}
// RegisterWithEmail creates a user and sends a verification link.
func (s *AuthService) RegisterWithEmail(ctx context.Context, email, firstName, lastName, mobilePhone, password string, roleID []byte) (*auth.User, error) {
email = strings.ToLower(strings.TrimSpace(email))
if email == "" || password == "" {
return nil, errors.New("email and password are required")
}
hash, salt, err := s.hashPassword(password)
if err != nil {
return nil, err
}
user := &auth.User{
ID: uuidv7.MustBytes(),
Email: email,
FirstName: firstName,
LastName: lastName,
MobilePhone: mobilePhone,
RoleID: roleID,
PasswordHash: hash,
SaltValue: salt,
}
if err := s.repo.CreateUser(ctx, user); err != nil {
return nil, err
}
if err := s.sendEmailVerification(ctx, user); err != nil {
return nil, err
}
return user, nil
}
func (s *AuthService) GetRoleIDByName(ctx context.Context, name string) ([]byte, error) {
if s.roleRepo == nil {
return nil, errors.New("role repo not configured")
}
role, err := s.roleRepo.GetRoleByName(ctx, name)
if err != nil || role == nil {
return nil, errors.New("role not found")
}
return role.ID, nil
}
func (s *AuthService) DefaultRoleName() string { return s.cfg.DefaultRoleName }
func (s *AuthService) RegisterDisabled() bool { return s.cfg.DisableRegister }
// LoginWithEmailPassword verifies email/password and returns the user.
func (s *AuthService) LoginWithEmailPassword(ctx context.Context, email, password string) (*auth.User, error) {
email = strings.ToLower(strings.TrimSpace(email))
user, err := s.repo.GetUserByEmail(ctx, email)
if err != nil || user == nil {
return nil, ErrInvalidCredentials
}
if !s.verifyPassword(password, user.PasswordHash, user.SaltValue) {
return nil, ErrInvalidCredentials
}
if user.EmailVerifiedAt == nil {
return nil, ErrEmailNotVerified
}
return user, nil
}
func (s *AuthService) IsTOTPEnabled(ctx context.Context, userID []byte) (bool, error) {
record, err := s.repo.GetUserTOTP(ctx, userID)
if err != nil || record == nil {
return false, err
}
return record.Enabled, nil
}
type TokenPair struct {
AccessToken string
RefreshToken string
}
func (s *AuthService) IssueTokens(ctx context.Context, user *auth.User) (TokenPair, error) {
if s.cfg.JWTAccessSecret == "" || s.cfg.JWTRefreshSecret == "" {
return TokenPair{}, errors.New("jwt secrets not configured")
}
now := time.Now()
accessClaims := jwt.MapClaims{
"sub": bytesToString(user.ID),
"iat": now.Unix(),
"exp": now.Add(s.cfg.JWTAccessTTL).Unix(),
"type": "access",
}
accessToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims).SignedString([]byte(s.cfg.JWTAccessSecret))
if err != nil {
return TokenPair{}, err
}
jti, err := randomToken(32)
if err != nil {
return TokenPair{}, err
}
refreshClaims := jwt.MapClaims{
"sub": bytesToString(user.ID),
"iat": now.Unix(),
"exp": now.Add(s.cfg.JWTRefreshTTL).Unix(),
"jti": jti,
"type": "refresh",
}
refreshToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims).SignedString([]byte(s.cfg.JWTRefreshSecret))
if err != nil {
return TokenPair{}, err
}
// Store refresh token jti to allow revoke
if s.tokens != nil {
_ = s.tokens.Set(ctx, refreshKey(jti), []byte("1"), s.cfg.JWTRefreshTTL)
}
return TokenPair{AccessToken: accessToken, RefreshToken: refreshToken}, nil
}
func (s *AuthService) RefreshTokens(ctx context.Context, refreshToken string) (TokenPair, error) {
if refreshToken == "" {
return TokenPair{}, ErrInvalidToken
}
parsed, err := jwt.Parse(refreshToken, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, ErrInvalidToken
}
return []byte(s.cfg.JWTRefreshSecret), nil
})
if err != nil || !parsed.Valid {
return TokenPair{}, ErrInvalidToken
}
claims, ok := parsed.Claims.(jwt.MapClaims)
if !ok {
return TokenPair{}, ErrInvalidToken
}
if claims["type"] != "refresh" {
return TokenPair{}, ErrInvalidToken
}
jti, _ := claims["jti"].(string)
sub, _ := claims["sub"].(string)
if jti == "" || sub == "" {
return TokenPair{}, ErrInvalidToken
}
if s.tokens != nil {
v, err := s.tokens.Get(ctx, refreshKey(jti))
if err != nil || v == nil {
return TokenPair{}, ErrInvalidToken
}
_ = s.tokens.Delete(ctx, refreshKey(jti))
}
userID, err := uuidv7.ParseString(sub)
if err != nil {
return TokenPair{}, ErrInvalidToken
}
user, err := s.repo.GetUserByID(ctx, userID)
if err != nil || user == nil {
return TokenPair{}, ErrInvalidToken
}
return s.IssueTokens(ctx, user)
}
func (s *AuthService) RevokeRefreshToken(ctx context.Context, refreshToken string) error {
if refreshToken == "" {
return nil
}
parsed, err := jwt.Parse(refreshToken, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, ErrInvalidToken
}
return []byte(s.cfg.JWTRefreshSecret), nil
})
if err != nil || !parsed.Valid {
return nil
}
claims, ok := parsed.Claims.(jwt.MapClaims)
if !ok {
return nil
}
jti, _ := claims["jti"].(string)
if jti != "" && s.tokens != nil {
_ = s.tokens.Delete(ctx, refreshKey(jti))
}
return nil
}
func (s *AuthService) ParseAccessToken(ctx context.Context, accessToken string) ([]byte, error) {
if accessToken == "" {
return nil, ErrInvalidToken
}
parsed, err := jwt.Parse(accessToken, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, ErrInvalidToken
}
return []byte(s.cfg.JWTAccessSecret), nil
})
if err != nil || !parsed.Valid {
return nil, ErrInvalidToken
}
claims, ok := parsed.Claims.(jwt.MapClaims)
if !ok || claims["type"] != "access" {
return nil, ErrInvalidToken
}
sub, _ := claims["sub"].(string)
if sub == "" {
return nil, ErrInvalidToken
}
return uuidv7.ParseString(sub)
}
func (s *AuthService) GetUserByID(ctx context.Context, id []byte) (*auth.User, error) {
return s.repo.GetUserByID(ctx, id)
}
func (s *AuthService) GetRoleByID(ctx context.Context, id []byte) (*auth.Role, error) {
if s.roleRepo == nil {
return nil, errors.New("role repo not configured")
}
return s.roleRepo.GetRoleByID(ctx, id)
}
func (s *AuthService) AccessTTL() time.Duration { return s.cfg.JWTAccessTTL }
func (s *AuthService) RefreshTTL() time.Duration { return s.cfg.JWTRefreshTTL }
func (s *AuthService) CookieDomain() string { return s.cfg.JWTCookieDomain }
func (s *AuthService) CookieSecure() bool { return s.cfg.JWTCookieSecure }
func (s *AuthService) CookieSameSite() string { return s.cfg.JWTCookieSameSite }
func (s *AuthService) AccessCookieName() string { return s.cfg.JWTAccessCookieName }
func (s *AuthService) RefreshCookieName() string { return s.cfg.JWTRefreshCookieName }
func (s *AuthService) SSOSuccessRedirectURL() string { return s.cfg.SSOSuccessRedirectURL }
// VerifyEmail verifies a token and marks the user as verified.
func (s *AuthService) VerifyEmail(ctx context.Context, token string) error {
if token == "" {
return ErrInvalidToken
}
key := emailVerifyKey(token)
data, err := s.tokens.Get(ctx, key)
if err != nil || len(data) != 16 {
return ErrInvalidToken
}
_ = s.tokens.Delete(ctx, key)
return s.repo.MarkEmailVerified(ctx, data)
}
// SSO Microsoft (Entra) login flow: exchange code -> upsert identity -> get user.
func (s *AuthService) LoginWithMicrosoft(ctx context.Context, code string) (*auth.UserIdentity, error) {
claims, err := s.sso.ExchangeCode(ctx, code)
if err != nil {
return nil, err
}
// If identity already exists, return it.
existing, err := s.repo.GetIdentityByProviderSubject(ctx, "microsoft", claims.Subject)
if err != nil {
return nil, err
}
if existing != nil && len(existing.UserID) == 16 {
return existing, nil
}
// Ensure user exists for this identity
roleID, err := s.GetRoleIDByName(ctx, "user")
if err != nil {
return nil, err
}
firstName, lastName := splitName(claims.Name)
user := &auth.User{
ID: uuidv7.MustBytes(),
Email: strings.ToLower(strings.TrimSpace(claims.Email)),
FirstName: firstName,
LastName: lastName,
RoleID: roleID,
EmailVerifiedAt: ptrTime(time.Now()),
}
if err := s.repo.CreateUser(ctx, user); err != nil {
return nil, err
}
identity := &auth.UserIdentity{
ID: uuidv7.MustBytes(),
UserID: user.ID,
Provider: "microsoft",
ProviderSubject: claims.Subject,
Email: strings.ToLower(strings.TrimSpace(claims.Email)),
}
if err := s.repo.UpsertIdentity(ctx, identity); err != nil {
return nil, err
}
return identity, nil
}
// TOTP verify: decrypt secret and verify code (6 digits).
func (s *AuthService) VerifyTOTP(ctx context.Context, userID []byte, code string) (bool, error) {
record, err := s.repo.GetUserTOTP(ctx, userID)
if err != nil || record == nil || !record.Enabled {
return false, nil
}
secret, err := s.protector.Decrypt(record.SecretEncrypted)
if err != nil {
return false, err
}
ok := verifyTOTP(string(secret), code)
if ok {
_ = s.repo.UpdateUserTOTPLastUsed(ctx, userID)
}
return ok, nil
}
func (s *AuthService) CreateTOTPChallenge(ctx context.Context, userID []byte) (string, error) {
if s.tokens == nil {
return "", errors.New("token store not configured")
}
token, err := randomToken(32)
if err != nil {
return "", err
}
if err := s.tokens.Set(ctx, totpChallengeKey(token), userID, s.cfg.TOTPChallengeTTL); err != nil {
return "", err
}
return token, nil
}
func (s *AuthService) ConsumeTOTPChallenge(ctx context.Context, token string) ([]byte, error) {
if s.tokens == nil || token == "" {
return nil, ErrInvalidToken
}
data, err := s.tokens.Get(ctx, totpChallengeKey(token))
if err != nil || data == nil {
return nil, ErrInvalidToken
}
_ = s.tokens.Delete(ctx, totpChallengeKey(token))
return data, nil
}
// SetupTOTP creates or replaces the user's TOTP secret and returns the provisioning URI.
func (s *AuthService) SetupTOTP(ctx context.Context, userID []byte, accountName string) (secret, otpAuthURL, issuer string, err error) {
if s.protector == nil {
return "", "", "", errors.New("secret protector not configured")
}
issuer = s.cfg.TOTPIssuer
key, err := totp.Generate(totp.GenerateOpts{
Issuer: issuer,
AccountName: accountName,
})
if err != nil {
return "", "", "", err
}
secret = key.Secret()
otpAuthURL = key.URL()
enc, err := s.protector.Encrypt([]byte(secret))
if err != nil {
return "", "", "", err
}
record := &auth.UserTOTP{
UserID: userID,
SecretEncrypted: enc,
Enabled: false,
}
if err := s.repo.UpsertUserTOTP(ctx, record); err != nil {
return "", "", "", err
}
return secret, otpAuthURL, issuer, nil
}
func (s *AuthService) DisableTOTP(ctx context.Context, userID []byte) error {
return s.repo.DisableUserTOTP(ctx, userID)
}
func (s *AuthService) ConfirmTOTP(ctx context.Context, userID []byte, code string) (bool, error) {
record, err := s.repo.GetUserTOTP(ctx, userID)
if err != nil || record == nil {
return false, ErrInvalidToken
}
secret, err := s.protector.Decrypt(record.SecretEncrypted)
if err != nil {
return false, err
}
ok := verifyTOTP(string(secret), code)
if !ok {
return false, nil
}
record.Enabled = true
if err := s.repo.UpsertUserTOTP(ctx, record); err != nil {
return false, err
}
return true, nil
}
func (s *AuthService) sendEmailVerification(ctx context.Context, user *auth.User) error {
if s.tokens == nil || s.cfg.EmailVerifyURL == "" {
return nil
}
token, err := randomToken(32)
if err != nil {
return err
}
if err := s.tokens.Set(ctx, emailVerifyKey(token), user.ID, s.cfg.EmailVerifyTTL); err != nil {
return err
}
link := strings.TrimRight(s.cfg.EmailVerifyURL, "/") + "?token=" + token
subject := "Verify your email"
body := "Click this link to verify your email: " + link
if s.emailQueue != nil {
return s.emailQueue.Enqueue(ctx, EmailJob{
To: user.Email,
Subject: subject,
Body: body,
})
}
if s.mailer != nil {
return s.mailer.Send(ctx, user.Email, subject, body)
}
return nil
}
func (s *AuthService) hashPassword(password string) (hash []byte, salt []byte, err error) {
salt = make([]byte, 16)
if _, err = rand.Read(salt); err != nil {
return nil, nil, err
}
peppered := []byte(password + s.cfg.PasswordPepper)
hash = argon2.IDKey(peppered, salt, s.cfg.HashTime, s.cfg.HashMemoryKB, s.cfg.HashThreads, s.cfg.HashKeyLen)
return hash, salt, nil
}
func (s *AuthService) verifyPassword(password string, hash, salt []byte) bool {
if len(hash) == 0 || len(salt) == 0 {
return false
}
peppered := []byte(password + s.cfg.PasswordPepper)
candidate := argon2.IDKey(peppered, salt, s.cfg.HashTime, s.cfg.HashMemoryKB, s.cfg.HashThreads, s.cfg.HashKeyLen)
return subtleConstantTimeCompare(hash, candidate)
}
func randomToken(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
func emailVerifyKey(token string) string {
return "auth:email_verify:" + token
}
func ssoStateKey(state string) string {
return "auth:sso_state:" + state
}
func refreshKey(jti string) string {
return "auth:refresh:" + jti
}
func totpChallengeKey(token string) string {
return "auth:totp_challenge:" + token
}
func bytesToString(b []byte) string {
s, _ := uuidv7.BytesToString(b)
return s
}
func splitName(full string) (string, string) {
full = strings.TrimSpace(full)
if full == "" {
return "User", ""
}
parts := strings.Fields(full)
if len(parts) == 1 {
return parts[0], ""
}
return parts[0], strings.Join(parts[1:], " ")
}
func ptrTime(t time.Time) *time.Time { return &t }
func subtleConstantTimeCompare(a, b []byte) bool {
if len(a) != len(b) {
return false
}
var v byte
for i := 0; i < len(a); i++ {
v |= a[i] ^ b[i]
}
return v == 0
}
func verifyTOTP(secret, code string) bool {
code = strings.ReplaceAll(code, " ", "")
ok, _ := totp.ValidateCustom(code, secret, time.Now().UTC(), totp.ValidateOpts{
Period: 30,
Skew: 1,
Digits: otp.DigitsSix,
Algorithm: otp.AlgorithmSHA1,
})
return ok
}
package service
import (
"context"
"encoding/json"
)
type EmailJob struct {
To string `json:"to"`
Subject string `json:"subject"`
Body string `json:"body"`
}
type EmailQueue interface {
Enqueue(ctx context.Context, job EmailJob) error
Dequeue(ctx context.Context) (*EmailJob, error)
}
func EncodeEmailJob(job EmailJob) ([]byte, error) {
return json.Marshal(job)
}
func DecodeEmailJob(b []byte) (*EmailJob, error) {
var job EmailJob
if err := json.Unmarshal(b, &job); err != nil {
return nil, err
}
return &job, nil
}
package service
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"wucher/internal/config"
)
type MicrosoftSSOClient struct {
cfg config.MicrosoftSSOConfig
client *http.Client
}
func NewMicrosoftSSO(cfg config.MicrosoftSSOConfig) *MicrosoftSSOClient {
return &MicrosoftSSOClient{
cfg: cfg,
client: &http.Client{
Timeout: 10 * time.Second,
},
}
}
func (m *MicrosoftSSOClient) AuthCodeURL(state string) string {
q := url.Values{}
q.Set("client_id", m.cfg.ClientID)
q.Set("response_type", "code")
q.Set("redirect_uri", m.cfg.RedirectURL)
q.Set("response_mode", "query")
q.Set("scope", strings.Join(m.cfg.Scopes, " "))
q.Set("state", state)
return fmt.Sprintf("%s%s/oauth2/v2.0/authorize?%s", m.cfg.Authority, m.cfg.TenantID, q.Encode())
}
func (m *MicrosoftSSOClient) ExchangeCode(ctx context.Context, code string) (MicrosoftClaims, error) {
if code == "" {
return MicrosoftClaims{}, errors.New("missing code")
}
form := url.Values{}
form.Set("client_id", m.cfg.ClientID)
form.Set("client_secret", m.cfg.ClientSecret)
form.Set("grant_type", "authorization_code")
form.Set("code", code)
form.Set("redirect_uri", m.cfg.RedirectURL)
form.Set("scope", strings.Join(m.cfg.Scopes, " "))
tokenURL := fmt.Sprintf("%s%s/oauth2/v2.0/token", m.cfg.Authority, m.cfg.TenantID)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(form.Encode()))
if err != nil {
return MicrosoftClaims{}, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := m.client.Do(req)
if err != nil {
return MicrosoftClaims{}, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return MicrosoftClaims{}, fmt.Errorf("token exchange failed: %s", string(body))
}
var token struct {
IDToken string `json:"id_token"`
}
if err := json.Unmarshal(body, &token); err != nil {
return MicrosoftClaims{}, err
}
if token.IDToken == "" {
return MicrosoftClaims{}, errors.New("missing id_token")
}
claims, err := parseJWTClaims(token.IDToken)
if err != nil {
return MicrosoftClaims{}, err
}
sub, _ := claims["sub"].(string)
email, _ := claims["email"].(string)
name, _ := claims["name"].(string)
if email == "" {
// Some tenants use preferred_username
email, _ = claims["preferred_username"].(string)
}
return MicrosoftClaims{
Subject: sub,
Email: email,
Name: name,
}, nil
}
func parseJWTClaims(token string) (map[string]any, error) {
parts := strings.Split(token, ".")
if len(parts) < 2 {
return nil, errors.New("invalid jwt")
}
payload, err := decodeSegment(parts[1])
if err != nil {
return nil, err
}
var claims map[string]any
if err := json.Unmarshal(payload, &claims); err != nil {
return nil, err
}
return claims, nil
}
func decodeSegment(seg string) ([]byte, error) {
seg = strings.ReplaceAll(seg, "-", "+")
seg = strings.ReplaceAll(seg, "_", "/")
switch len(seg) % 4 {
case 2:
seg += "=="
case 3:
seg += "="
}
return io.ReadAll(base64.NewDecoder(base64.StdEncoding, strings.NewReader(seg)))
}
package service
import (
"context"
"errors"
"strings"
"wucher/internal/domain/auth"
)
type RoleService struct {
repo auth.RoleRepository
}
func NewRoleService(repo auth.RoleRepository) *RoleService {
return &RoleService{repo: repo}
}
func (s *RoleService) Create(ctx context.Context, role *auth.Role) error {
if strings.TrimSpace(role.Name) == "" {
return errors.New("name is required")
}
return s.repo.CreateRole(ctx, role)
}
func (s *RoleService) Update(ctx context.Context, role *auth.Role) error {
if strings.TrimSpace(role.Name) == "" {
return errors.New("name is required")
}
return s.repo.UpdateRole(ctx, role)
}
func (s *RoleService) Delete(ctx context.Context, id []byte) error {
return s.repo.DeleteRole(ctx, id)
}
func (s *RoleService) GetByID(ctx context.Context, id []byte) (*auth.Role, error) {
return s.repo.GetRoleByID(ctx, id)
}
func (s *RoleService) GetByName(ctx context.Context, name string) (*auth.Role, error) {
return s.repo.GetRoleByName(ctx, name)
}
func (s *RoleService) List(ctx context.Context, filterName, sort string, limit, offset int) ([]auth.Role, int64, error) {
return s.repo.ListRoles(ctx, filterName, normalizeSort(sort), limit, offset)
}
func normalizeSort(sort string) string {
// Allow list: name, -name, created_at, -created_at, updated_at, -updated_at
switch sort {
case "name":
return "name ASC"
case "-name":
return "name DESC"
case "created_at":
return "created_at ASC"
case "-created_at":
return "created_at DESC"
case "updated_at":
return "updated_at ASC"
case "-updated_at":
return "updated_at DESC"
default:
return ""
}
}
package service
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"errors"
"io"
)
type AESGCMProtector struct {
key []byte
}
func NewAESGCMProtectorFromBase64(keyB64 string) (*AESGCMProtector, error) {
if keyB64 == "" {
return nil, errors.New("empty key")
}
key, err := base64.StdEncoding.DecodeString(keyB64)
if err != nil {
return nil, err
}
if len(key) != 32 {
return nil, errors.New("key must be 32 bytes (base64-encoded)")
}
return &AESGCMProtector{key: key}, nil
}
func (p *AESGCMProtector) Encrypt(plain []byte) ([]byte, error) {
block, err := aes.NewCipher(p.key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, err
}
ciphertext := gcm.Seal(nonce, nonce, plain, nil)
return ciphertext, nil
}
func (p *AESGCMProtector) Decrypt(ciphertext []byte) ([]byte, error) {
block, err := aes.NewCipher(p.key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
if len(ciphertext) < gcm.NonceSize() {
return nil, errors.New("ciphertext too short")
}
nonce := ciphertext[:gcm.NonceSize()]
data := ciphertext[gcm.NonceSize():]
return gcm.Open(nil, nonce, data, nil)
}
package service
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net"
"net/mail"
"net/smtp"
"strings"
"time"
"wucher/internal/config"
)
type SMTPSender struct {
cfg config.SMTPConfig
}
func NewSMTPSender(cfg config.SMTPConfig) *SMTPSender {
return &SMTPSender{cfg: cfg}
}
func (s *SMTPSender) Send(ctx context.Context, to, subject, body string) error {
if s.cfg.Host == "" || s.cfg.From == "" {
return errors.New("smtp not configured")
}
if to == "" {
return errors.New("recipient is required")
}
addr := fmt.Sprintf("%s:%d", s.cfg.Host, s.cfg.Port)
fromAddr, err := mail.ParseAddress(s.cfg.From)
if err != nil {
return err
}
toAddr, err := mail.ParseAddress(to)
if err != nil {
return err
}
msg := buildMessage(s.cfg.From, to, subject, body)
var c *smtp.Client
var conn net.Conn
dialer := &net.Dialer{Timeout: 10 * time.Second}
tlsCfg := &tls.Config{
ServerName: s.cfg.Host,
InsecureSkipVerify: s.cfg.InsecureSkipVerify,
}
if s.cfg.Port == 465 {
conn, err = tls.DialWithDialer(dialer, "tcp", addr, tlsCfg)
if err != nil {
return err
}
c, err = smtp.NewClient(conn, s.cfg.Host)
if err != nil {
_ = conn.Close()
return err
}
} else {
conn, err = dialer.DialContext(ctx, "tcp", addr)
if err != nil {
return err
}
c, err = smtp.NewClient(conn, s.cfg.Host)
if err != nil {
_ = conn.Close()
return err
}
if ok, _ := c.Extension("STARTTLS"); ok {
if err := c.StartTLS(tlsCfg); err != nil {
_ = c.Close()
return err
}
}
}
defer func() { _ = c.Close() }()
if s.cfg.Username != "" {
auth := smtp.PlainAuth("", s.cfg.Username, s.cfg.Password, s.cfg.Host)
if err := c.Auth(auth); err != nil {
return err
}
}
if err := c.Mail(fromAddr.Address); err != nil {
return err
}
if err := c.Rcpt(toAddr.Address); err != nil {
return err
}
w, err := c.Data()
if err != nil {
return err
}
if _, err := w.Write([]byte(msg)); err != nil {
_ = w.Close()
return err
}
if err := w.Close(); err != nil {
return err
}
return c.Quit()
}
func buildMessage(from, to, subject, body string) string {
subject = strings.ReplaceAll(subject, "\n", "")
subject = strings.ReplaceAll(subject, "\r", "")
return strings.Join([]string{
fmt.Sprintf("From: %s", from),
fmt.Sprintf("To: %s", to),
fmt.Sprintf("Subject: %s", subject),
"MIME-Version: 1.0",
`Content-Type: text/plain; charset="UTF-8"`,
"",
body,
}, "\r\n")
}
package service
import (
"context"
"wucher/internal/domain/user"
)
type UserService struct {
repo user.Repository
}
func NewUserService(repo user.Repository) *UserService {
return &UserService{repo: repo}
}
func (s *UserService) Create(ctx context.Context, u *user.User) error {
return s.repo.Create(ctx, u)
}
func (s *UserService) GetByID(ctx context.Context, id []byte) (*user.User, error) {
return s.repo.GetByID(ctx, id)
}
func (s *UserService) GetByEmail(ctx context.Context, email string) (*user.User, error) {
return s.repo.GetByEmail(ctx, email)
}
package handlers
import (
"strings"
"github.com/gofiber/fiber/v2"
"wucher/internal/pkg/uuidv7"
"wucher/internal/service"
"wucher/internal/transport/http/dto"
"wucher/internal/transport/http/jsonapi"
"wucher/internal/transport/http/response"
"wucher/internal/transport/http/validators"
)
type AuthHandler struct {
svc *service.AuthService
validate *validators.Validator
}
func NewAuthHandler(svc *service.AuthService) *AuthHandler {
return &AuthHandler{
svc: svc,
validate: validators.New(),
}
}
// Register godoc
// @Summary Register user with email & password
// @Description Creates user (default role: admin) and sends verification link to email.
// @Description Next: open verify link -> then login.
// @Tags Auth
// @Accept json
// @Produce json
// @Param request body dto.RegisterRequest true "JSON:API Register request"
// @Success 201 {object} dto.UserResponse
// @Failure 422 {object} dto.ErrorResponse
// @Failure 400 {object} dto.ErrorResponse
// @Router /api/v1/auth/register [post]
func (h *AuthHandler) Register(c *fiber.Ctx) error {
if h.svc.RegisterDisabled() {
return response.WriteErrors(c, fiber.StatusForbidden, []jsonapi.ErrorObject{{
Status: "403",
Title: "Registration disabled",
Detail: "registration is disabled in this environment",
}})
}
var req dto.RegisterRequest
if err := c.BodyParser(&req); err != nil {
return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "Invalid JSON",
Detail: err.Error(),
}})
}
if errs := h.validate.ValidateStruct(req); errs != nil {
return response.WriteErrors(c, fiber.StatusUnprocessableEntity, errs)
}
roleID, err := h.svc.GetRoleIDByName(c.Context(), h.svc.DefaultRoleName())
if err != nil {
return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "Role not found",
Detail: "default role not found",
}})
}
user, err := h.svc.RegisterWithEmail(
c.Context(),
req.Data.Attributes.Email,
req.Data.Attributes.FirstName,
req.Data.Attributes.LastName,
strings.TrimSpace(req.Data.Attributes.MobilePhone),
req.Data.Attributes.Password,
roleID,
)
if err != nil {
return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "Registration failed",
Detail: err.Error(),
}})
}
tokens, err := h.svc.IssueTokens(c.Context(), user)
if err != nil {
return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "Token issue failed",
Detail: err.Error(),
}})
}
setAuthCookies(c, h.svc, tokens)
id, _ := uuidv7.BytesToString(user.ID)
resp := jsonapi.Resource{
Type: "user",
ID: id,
Attributes: map[string]any{
"email": user.Email,
"first_name": user.FirstName,
"last_name": user.LastName,
"role_id": func() string { s, _ := uuidv7.BytesToString(roleID); return s }(),
"mobile_phone": user.MobilePhone,
},
}
return response.Write(c, fiber.StatusCreated, resp)
}
// Login godoc
// @Summary Login with email & password
// @Description If TOTP enabled, returns 202 + challenge_token. Then call /auth/totp/verify.
// @Tags Auth
// @Accept json
// @Produce json
// @Param request body dto.LoginRequest true "JSON:API Login request"
// @Success 200 {object} dto.UserResponse
// @Success 202 {object} dto.LoginTOTPRequiredResponse
// @Failure 401 {object} dto.ErrorResponse
// @Failure 422 {object} dto.ErrorResponse
// @Router /api/v1/auth/login [post]
func (h *AuthHandler) Login(c *fiber.Ctx) error {
var req dto.LoginRequest
if err := c.BodyParser(&req); err != nil {
return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "Invalid JSON",
Detail: err.Error(),
}})
}
if errs := h.validate.ValidateStruct(req); errs != nil {
return response.WriteErrors(c, fiber.StatusUnprocessableEntity, errs)
}
user, err := h.svc.LoginWithEmailPassword(c.Context(), req.Data.Attributes.Email, req.Data.Attributes.Password)
if err != nil {
return response.WriteErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
Status: "401",
Title: "Unauthorized",
Detail: err.Error(),
}})
}
if ok, _ := h.svc.IsTOTPEnabled(c.Context(), user.ID); ok {
token, err := h.svc.CreateTOTPChallenge(c.Context(), user.ID)
if err != nil {
return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "TOTP challenge failed",
Detail: err.Error(),
}})
}
return response.Write(c, fiber.StatusAccepted, jsonapi.Resource{
Type: "auth_totp_required",
Attributes: map[string]any{
"requires_totp": true,
"challenge_token": token,
},
})
}
tokens, err := h.svc.IssueTokens(c.Context(), user)
if err != nil {
return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "Token issue failed",
Detail: err.Error(),
}})
}
setAuthCookies(c, h.svc, tokens)
id, _ := uuidv7.BytesToString(user.ID)
roleID, _ := uuidv7.BytesToString(user.RoleID)
var emailVerifiedAt string
if user.EmailVerifiedAt != nil {
emailVerifiedAt = user.EmailVerifiedAt.UTC().Format("2006-01-02T15:04:05Z07:00")
}
var roleAttrs any
role, _ := h.svc.GetRoleByID(c.Context(), user.RoleID)
if role != nil {
roleIDStr, _ := uuidv7.BytesToString(role.ID)
roleAttrs = map[string]any{
"id": roleIDStr,
"name": role.Name,
"description": role.Description,
}
}
resp := jsonapi.Resource{
Type: "user",
ID: id,
Attributes: map[string]any{
"email": user.Email,
"first_name": user.FirstName,
"last_name": user.LastName,
"mobile_phone": user.MobilePhone,
"role_id": roleID,
"email_verified_at": emailVerifiedAt,
"role": roleAttrs,
},
}
return response.Write(c, fiber.StatusOK, resp)
}
// VerifyEmail godoc
// @Summary Verify email
// @Description Confirms email verification after register.
// @Tags Auth
// @Produce json
// @Param token query string true "Verification token"
// @Success 200 {object} dto.VerifyEmailResponse
// @Failure 400 {object} dto.ErrorResponse
// @Router /api/v1/auth/verify-email [get]
func (h *AuthHandler) VerifyEmail(c *fiber.Ctx) error {
token := c.Query("token")
if token == "" {
return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "Invalid token",
Detail: "token is required",
Source: &jsonapi.ErrorSource{Pointer: "/query/token"},
}})
}
if err := h.svc.VerifyEmail(c.Context(), token); err != nil {
return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "Invalid token",
Detail: err.Error(),
}})
}
return response.Write(c, fiber.StatusOK, jsonapi.Resource{
Type: "auth_verify_email",
Attributes: map[string]any{
"verified": true,
},
})
}
// MicrosoftLogin godoc
// @Summary Microsoft Entra SSO login
// @Description Returns the Microsoft authorization URL. Browser should redirect user to this URL.
// @Tags Auth
// @Produce json
// @Success 200 {object} dto.MicrosoftLoginResponse
// @Failure 400 {object} dto.ErrorResponse
// @Router /api/v1/auth/microsoft/login [get]
func (h *AuthHandler) MicrosoftLogin(c *fiber.Ctx) error {
urlStr, err := h.svc.BeginMicrosoftLogin(c.Context())
if err != nil {
return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "SSO not configured",
Detail: err.Error(),
}})
}
return response.Write(c, fiber.StatusOK, jsonapi.Resource{
Type: "auth_microsoft_url",
Attributes: map[string]any{
"url": urlStr,
},
})
}
// MicrosoftCallback godoc
// @Summary Microsoft Entra SSO callback
// @Description Sets auth cookies and redirects to AUTH_SSO_SUCCESS_REDIRECT if configured.
// @Tags Auth
// @Produce json
// @Param code query string true "Authorization code"
// @Param state query string true "State"
// @Success 200 {object} dto.MicrosoftCallbackResponse
// @Failure 400 {object} dto.ErrorResponse
// @Router /api/v1/auth/microsoft/callback [get]
func (h *AuthHandler) MicrosoftCallback(c *fiber.Ctx) error {
code := c.Query("code")
state := c.Query("state")
if code == "" || state == "" {
return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "Invalid callback",
Detail: "code and state are required",
}})
}
identity, err := h.svc.CompleteMicrosoftLogin(c.Context(), code, state)
if err != nil {
return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "SSO login failed",
Detail: err.Error(),
}})
}
// Issue cookies and redirect to FE if configured
user, err := h.svc.GetUserByID(c.Context(), identity.UserID)
if err == nil && user != nil {
tokens, err := h.svc.IssueTokens(c.Context(), user)
if err == nil {
setAuthCookies(c, h.svc, tokens)
}
}
if redirect := h.svc.SSOSuccessRedirectURL(); redirect != "" {
return c.Redirect(redirect, fiber.StatusFound)
}
id, _ := uuidv7.BytesToString(identity.ID)
return response.Write(c, fiber.StatusOK, jsonapi.Resource{
Type: "auth_identity",
ID: id,
Attributes: map[string]any{
"provider": identity.Provider,
"provider_subject": identity.ProviderSubject,
"email": identity.Email,
},
})
}
// Me godoc
// @Summary Get current user
// @Tags Auth
// @Produce json
// @Success 200 {object} dto.UserResponse
// @Failure 401 {object} dto.ErrorResponse
// @Router /api/v1/auth/me [get]
func (h *AuthHandler) Me(c *fiber.Ctx) error {
userID, ok := c.Locals("user_id").([]byte)
if !ok || len(userID) == 0 {
return response.WriteErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
Status: "401",
Title: "Unauthorized",
Detail: "missing auth context",
}})
}
user, err := h.svc.GetUserByID(c.Context(), userID)
if err != nil || user == nil {
return response.WriteErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
Status: "401",
Title: "Unauthorized",
Detail: "user not found",
}})
}
id, _ := uuidv7.BytesToString(user.ID)
roleID, _ := uuidv7.BytesToString(user.RoleID)
var emailVerifiedAt string
if user.EmailVerifiedAt != nil {
emailVerifiedAt = user.EmailVerifiedAt.UTC().Format("2006-01-02T15:04:05Z07:00")
}
var roleAttrs any
role, _ := h.svc.GetRoleByID(c.Context(), user.RoleID)
if role != nil {
roleIDStr, _ := uuidv7.BytesToString(role.ID)
roleAttrs = map[string]any{
"id": roleIDStr,
"name": role.Name,
"description": role.Description,
}
}
return response.Write(c, fiber.StatusOK, jsonapi.Resource{
Type: "user",
ID: id,
Attributes: map[string]any{
"email": user.Email,
"first_name": user.FirstName,
"last_name": user.LastName,
"mobile_phone": user.MobilePhone,
"role_id": roleID,
"email_verified_at": emailVerifiedAt,
"role": roleAttrs,
},
})
}
// Refresh godoc
// @Summary Refresh access token
// @Tags Auth
// @Produce json
// @Success 200 {object} dto.TokenResponse
// @Failure 401 {object} dto.ErrorResponse
// @Router /api/v1/auth/refresh [post]
func (h *AuthHandler) Refresh(c *fiber.Ctx) error {
refreshToken := c.Cookies(h.svc.RefreshCookieName())
tokens, err := h.svc.RefreshTokens(c.Context(), refreshToken)
if err != nil {
return response.WriteErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
Status: "401",
Title: "Unauthorized",
Detail: err.Error(),
}})
}
setAuthCookies(c, h.svc, tokens)
return response.Write(c, fiber.StatusOK, jsonapi.Resource{
Type: "auth_tokens",
Attributes: map[string]any{
"access_expires_in": int64(h.svc.AccessTTL().Seconds()),
"refresh_expires_in": int64(h.svc.RefreshTTL().Seconds()),
},
})
}
// Logout godoc
// @Summary Logout (revoke refresh)
// @Tags Auth
// @Produce json
// @Success 200 {object} dto.VerifyEmailResponse
// @Router /api/v1/auth/logout [post]
func (h *AuthHandler) Logout(c *fiber.Ctx) error {
refreshToken := c.Cookies(h.svc.RefreshCookieName())
_ = h.svc.RevokeRefreshToken(c.Context(), refreshToken)
clearAuthCookies(c, h.svc)
return response.Write(c, fiber.StatusOK, jsonapi.Resource{
Type: "auth_logout",
Attributes: map[string]any{
"logged_out": true,
},
})
}
// TOTPSetup godoc
// @Summary Setup TOTP
// @Description Generates secret and otpauth URL. TOTP is NOT enabled until confirmed.
// @Tags Auth
// @Accept json
// @Produce json
// @Param request body dto.TOTPSetupRequest true "JSON:API TOTP setup request"
// @Success 200 {object} dto.TOTPSetupResponse
// @Failure 422 {object} dto.ErrorResponse
// @Router /api/v1/auth/totp/setup [post]
func (h *AuthHandler) TOTPSetup(c *fiber.Ctx) error {
var req dto.TOTPSetupRequest
if err := c.BodyParser(&req); err != nil {
return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "Invalid JSON",
Detail: err.Error(),
}})
}
if errs := h.validate.ValidateStruct(req); errs != nil {
return response.WriteErrors(c, fiber.StatusUnprocessableEntity, errs)
}
userID, err := uuidv7.ParseString(req.Data.Attributes.UserID)
if err != nil {
return response.WriteErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
Status: "422",
Title: "Validation error",
Detail: "user_id is invalid UUID",
Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/user_id"},
}})
}
secret, otpURL, issuer, err := h.svc.SetupTOTP(c.Context(), userID, req.Data.Attributes.Email)
if err != nil {
return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "TOTP setup failed",
Detail: err.Error(),
}})
}
return response.Write(c, fiber.StatusOK, jsonapi.Resource{
Type: "auth_totp_setup",
Attributes: map[string]any{
"secret": secret,
"otpauth_url": otpURL,
"issuer": issuer,
},
})
}
// TOTPConfirm godoc
// @Summary Confirm TOTP setup
// @Description Validates initial TOTP code and enables TOTP for future logins.
// @Tags Auth
// @Accept json
// @Produce json
// @Param request body dto.TOTPConfirmRequest true "JSON:API TOTP confirm request"
// @Success 200 {object} dto.VerifyEmailResponse
// @Failure 422 {object} dto.ErrorResponse
// @Router /api/v1/auth/totp/confirm [post]
func (h *AuthHandler) TOTPConfirm(c *fiber.Ctx) error {
var req dto.TOTPConfirmRequest
if err := c.BodyParser(&req); err != nil {
return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "Invalid JSON",
Detail: err.Error(),
}})
}
if errs := h.validate.ValidateStruct(req); errs != nil {
return response.WriteErrors(c, fiber.StatusUnprocessableEntity, errs)
}
userID, err := uuidv7.ParseString(req.Data.Attributes.UserID)
if err != nil {
return response.WriteErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
Status: "422",
Title: "Validation error",
Detail: "user_id is invalid UUID",
Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/user_id"},
}})
}
ok, err := h.svc.ConfirmTOTP(c.Context(), userID, strings.TrimSpace(req.Data.Attributes.Code))
if err != nil || !ok {
return response.WriteErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
Status: "401",
Title: "Invalid TOTP",
Detail: "code is invalid or expired",
}})
}
return response.Write(c, fiber.StatusOK, jsonapi.Resource{
Type: "auth_totp_confirm",
Attributes: map[string]any{
"confirmed": true,
},
})
}
// TOTPVerify godoc
// @Summary Verify TOTP code
// @Description Completes login when TOTP is enabled and returns auth cookies.
// @Tags Auth
// @Accept json
// @Produce json
// @Param request body dto.TOTPVerifyRequest true "JSON:API TOTP verify request"
// @Success 200 {object} dto.TokenResponse
// @Failure 422 {object} dto.ErrorResponse
// @Router /api/v1/auth/totp/verify [post]
func (h *AuthHandler) TOTPVerify(c *fiber.Ctx) error {
var req dto.TOTPVerifyRequest
if err := c.BodyParser(&req); err != nil {
return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "Invalid JSON",
Detail: err.Error(),
}})
}
if errs := h.validate.ValidateStruct(req); errs != nil {
return response.WriteErrors(c, fiber.StatusUnprocessableEntity, errs)
}
userID, err := h.svc.ConsumeTOTPChallenge(c.Context(), strings.TrimSpace(req.Data.Attributes.ChallengeToken))
if err != nil {
return response.WriteErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
Status: "401",
Title: "Invalid challenge",
Detail: "challenge token is invalid or expired",
}})
}
ok, err := h.svc.VerifyTOTP(c.Context(), userID, strings.TrimSpace(req.Data.Attributes.Code))
if err != nil || !ok {
return response.WriteErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
Status: "401",
Title: "Invalid TOTP",
Detail: "code is invalid or expired",
}})
}
user, err := h.svc.GetUserByID(c.Context(), userID)
if err != nil || user == nil {
return response.WriteErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
Status: "401",
Title: "Unauthorized",
Detail: "user not found",
}})
}
tokens, err := h.svc.IssueTokens(c.Context(), user)
if err != nil {
return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "Token issue failed",
Detail: err.Error(),
}})
}
setAuthCookies(c, h.svc, tokens)
return response.Write(c, fiber.StatusOK, jsonapi.Resource{
Type: "auth_tokens",
Attributes: map[string]any{
"access_expires_in": int64(h.svc.AccessTTL().Seconds()),
"refresh_expires_in": int64(h.svc.RefreshTTL().Seconds()),
},
})
}
// TOTPDisable godoc
// @Summary Disable TOTP
// @Tags Auth
// @Accept json
// @Produce json
// @Param request body dto.TOTPDisableRequest true "JSON:API TOTP disable request"
// @Success 200 {object} dto.VerifyEmailResponse
// @Failure 422 {object} dto.ErrorResponse
// @Router /api/v1/auth/totp/disable [post]
func (h *AuthHandler) TOTPDisable(c *fiber.Ctx) error {
var req dto.TOTPDisableRequest
if err := c.BodyParser(&req); err != nil {
return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "Invalid JSON",
Detail: err.Error(),
}})
}
if errs := h.validate.ValidateStruct(req); errs != nil {
return response.WriteErrors(c, fiber.StatusUnprocessableEntity, errs)
}
userID, err := uuidv7.ParseString(req.Data.Attributes.UserID)
if err != nil {
return response.WriteErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
Status: "422",
Title: "Validation error",
Detail: "user_id is invalid UUID",
Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/user_id"},
}})
}
if err := h.svc.DisableTOTP(c.Context(), userID); err != nil {
return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "Disable TOTP failed",
Detail: err.Error(),
}})
}
return response.Write(c, fiber.StatusOK, jsonapi.Resource{
Type: "auth_totp_disable",
Attributes: map[string]any{
"disabled": true,
},
})
}
func setAuthCookies(c *fiber.Ctx, svc *service.AuthService, tokens service.TokenPair) {
c.Cookie(&fiber.Cookie{
Name: svc.AccessCookieName(),
Value: tokens.AccessToken,
HTTPOnly: true,
Secure: svc.CookieSecure(),
SameSite: parseSameSite(svc.CookieSameSite()),
Domain: svc.CookieDomain(),
Path: "/",
MaxAge: int(svc.AccessTTL().Seconds()),
})
c.Cookie(&fiber.Cookie{
Name: svc.RefreshCookieName(),
Value: tokens.RefreshToken,
HTTPOnly: true,
Secure: svc.CookieSecure(),
SameSite: parseSameSite(svc.CookieSameSite()),
Domain: svc.CookieDomain(),
Path: "/api/v1/auth/refresh",
MaxAge: int(svc.RefreshTTL().Seconds()),
})
}
func clearAuthCookies(c *fiber.Ctx, svc *service.AuthService) {
c.Cookie(&fiber.Cookie{
Name: svc.AccessCookieName(),
Value: "",
HTTPOnly: true,
Secure: svc.CookieSecure(),
SameSite: parseSameSite(svc.CookieSameSite()),
Domain: svc.CookieDomain(),
Path: "/",
MaxAge: -1,
})
c.Cookie(&fiber.Cookie{
Name: svc.RefreshCookieName(),
Value: "",
HTTPOnly: true,
Secure: svc.CookieSecure(),
SameSite: parseSameSite(svc.CookieSameSite()),
Domain: svc.CookieDomain(),
Path: "/api/v1/auth/refresh",
MaxAge: -1,
})
}
func parseSameSite(v string) string {
switch strings.ToLower(v) {
case "strict":
return "Strict"
case "none":
return "None"
default:
return "Lax"
}
}
package handlers
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/transport/http/response"
"wucher/internal/transport/http/jsonapi"
)
type HealthHandler struct{}
func NewHealthHandler() *HealthHandler {
return &HealthHandler{}
}
func (h *HealthHandler) Handle(c *fiber.Ctx) error {
return response.Write(c, fiber.StatusOK, jsonapi.Resource{
Type: "health",
Attributes: map[string]any{
"status": "ok",
},
})
}
package handlers
import (
"strconv"
"strings"
"time"
"github.com/gofiber/fiber/v2"
"wucher/internal/domain/auth"
"wucher/internal/pkg/uuidv7"
"wucher/internal/service"
"wucher/internal/transport/http/dto"
"wucher/internal/transport/http/jsonapi"
"wucher/internal/transport/http/response"
"wucher/internal/transport/http/validators"
)
type RoleHandler struct {
svc *service.RoleService
validate *validators.Validator
}
func NewRoleHandler(svc *service.RoleService) *RoleHandler {
return &RoleHandler{
svc: svc,
validate: validators.New(),
}
}
// CreateRole godoc
// @Summary Create role
// @Description Create a new role. Name must be unique.
// @Tags Roles
// @Accept json
// @Produce json
// @Param request body dto.RoleCreateRequest true "JSON:API Role create request"
// @Success 201 {object} dto.RoleResponse
// @Failure 422 {object} dto.ErrorResponse
// @Failure 400 {object} dto.ErrorResponse
// @Router /api/v1/roles [post]
func (h *RoleHandler) Create(c *fiber.Ctx) error {
var req dto.RoleCreateRequest
if err := c.BodyParser(&req); err != nil {
return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "Invalid JSON",
Detail: err.Error(),
}})
}
if errs := h.validate.ValidateStruct(req); errs != nil {
return response.WriteErrors(c, fiber.StatusUnprocessableEntity, errs)
}
role := &auth.Role{
Name: strings.TrimSpace(req.Data.Attributes.Name),
Description: strings.TrimSpace(req.Data.Attributes.Description),
}
if err := h.svc.Create(c.Context(), role); err != nil {
return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "Create failed",
Detail: err.Error(),
}})
}
return response.Write(c, fiber.StatusCreated, roleResource(role))
}
// UpdateRole godoc
// @Summary Update role
// @Description Update role by ID. Name must be unique.
// @Tags Roles
// @Accept json
// @Produce json
// @Param id path string true "Role ID (UUIDv7)"
// @Param request body dto.RoleUpdateRequest true "JSON:API Role update request"
// @Success 200 {object} dto.RoleResponse
// @Failure 422 {object} dto.ErrorResponse
// @Failure 404 {object} dto.ErrorResponse
// @Router /api/v1/roles/{id} [put]
func (h *RoleHandler) Update(c *fiber.Ctx) error {
idStr := c.Params("id")
roleID, err := uuidv7.ParseString(idStr)
if err != nil {
return response.WriteErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
Status: "422",
Title: "Validation error",
Detail: "id is invalid UUID",
Source: &jsonapi.ErrorSource{Pointer: "/path/id"},
}})
}
var req dto.RoleUpdateRequest
if err := c.BodyParser(&req); err != nil {
return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "Invalid JSON",
Detail: err.Error(),
}})
}
if errs := h.validate.ValidateStruct(req); errs != nil {
return response.WriteErrors(c, fiber.StatusUnprocessableEntity, errs)
}
existing, err := h.svc.GetByID(c.Context(), roleID)
if err != nil || existing == nil {
return response.WriteErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{
Status: "404",
Title: "Not found",
Detail: "role not found",
}})
}
existing.Name = strings.TrimSpace(req.Data.Attributes.Name)
existing.Description = strings.TrimSpace(req.Data.Attributes.Description)
if err := h.svc.Update(c.Context(), existing); err != nil {
return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "Update failed",
Detail: err.Error(),
}})
}
return response.Write(c, fiber.StatusOK, roleResource(existing))
}
// DeleteRole godoc
// @Summary Delete role
// @Tags Roles
// @Produce json
// @Param id path string true "Role ID (UUIDv7)"
// @Success 200 {object} dto.VerifyEmailResponse
// @Failure 404 {object} dto.ErrorResponse
// @Router /api/v1/roles/{id} [delete]
func (h *RoleHandler) Delete(c *fiber.Ctx) error {
idStr := c.Params("id")
roleID, err := uuidv7.ParseString(idStr)
if err != nil {
return response.WriteErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
Status: "422",
Title: "Validation error",
Detail: "id is invalid UUID",
Source: &jsonapi.ErrorSource{Pointer: "/path/id"},
}})
}
if err := h.svc.Delete(c.Context(), roleID); err != nil {
return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "Delete failed",
Detail: err.Error(),
}})
}
return response.Write(c, fiber.StatusOK, jsonapi.Resource{
Type: "role_delete",
Attributes: map[string]any{
"deleted": true,
},
})
}
// GetRoleByID godoc
// @Summary Get role by ID
// @Tags Roles
// @Produce json
// @Param id path string true "Role ID (UUIDv7)"
// @Success 200 {object} dto.RoleResponse
// @Failure 404 {object} dto.ErrorResponse
// @Router /api/v1/roles/{id} [get]
func (h *RoleHandler) GetByID(c *fiber.Ctx) error {
idStr := c.Params("id")
roleID, err := uuidv7.ParseString(idStr)
if err != nil {
return response.WriteErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
Status: "422",
Title: "Validation error",
Detail: "id is invalid UUID",
Source: &jsonapi.ErrorSource{Pointer: "/path/id"},
}})
}
role, err := h.svc.GetByID(c.Context(), roleID)
if err != nil || role == nil {
return response.WriteErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{
Status: "404",
Title: "Not found",
Detail: "role not found",
}})
}
return response.Write(c, fiber.StatusOK, roleResource(role))
}
// ListRoles godoc
// @Summary List roles
// @Description JSON:API list with pagination, filtering and sorting. Sort values: name, -name, created_at, -created_at, updated_at, -updated_at.
// @Tags Roles
// @Produce json
// @Param filter[name] query string false "Filter by name (contains)"
// @Param page[number] query int false "Page number (default 1)"
// @Param page[size] query int false "Page size (default 20, max 100)"
// @Param sort query string false "Sort order"
// @Success 200 {object} dto.RoleListResponse
// @Router /api/v1/roles [get]
func (h *RoleHandler) List(c *fiber.Ctx) error {
filterName := c.Query("filter[name]")
pageNumber := parseIntDefault(c.Query("page[number]"), 1)
pageSize := parseIntDefault(c.Query("page[size]"), 20)
if pageSize > 100 {
pageSize = 100
}
if pageNumber < 1 {
pageNumber = 1
}
offset := (pageNumber - 1) * pageSize
sort := c.Query("sort")
roles, total, err := h.svc.List(c.Context(), filterName, sort, pageSize, offset)
if err != nil {
return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "List failed",
Detail: err.Error(),
}})
}
data := make([]dto.RoleResource, 0, len(roles))
for i := range roles {
data = append(data, roleResource(&roles[i]))
}
meta := map[string]any{
"page_number": pageNumber,
"page_size": pageSize,
"total": total,
}
return response.WriteWithMeta(c, fiber.StatusOK, data, meta)
}
// ListRolesDatatable godoc
// @Summary List roles (datatable)
// @Description DataTables-style params with JSON:API response. Use draw/start/length/search[value].
// @Tags Roles
// @Produce json
// @Param draw query int false "Draw counter"
// @Param start query int false "Offset"
// @Param length query int false "Page size"
// @Param search[value] query string false "Search term"
// @Success 200 {object} dto.RoleDataTableResponse
// @Router /api/v1/roles/datatable [get]
func (h *RoleHandler) ListDatatable(c *fiber.Ctx) error {
draw := parseIntDefault(c.Query("draw"), 1)
start := parseIntDefault(c.Query("start"), 0)
length := parseIntDefault(c.Query("length"), 20)
if length > 100 {
length = 100
}
search := c.Query("search[value]")
roles, total, err := h.svc.List(c.Context(), search, "", length, start)
if err != nil {
return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "List failed",
Detail: err.Error(),
}})
}
data := make([]dto.RoleResource, 0, len(roles))
for i := range roles {
data = append(data, roleResource(&roles[i]))
}
meta := map[string]any{
"draw": draw,
"records_total": total,
"records_filtered": total,
}
return response.WriteWithMeta(c, fiber.StatusOK, data, meta)
}
func roleResource(role *auth.Role) dto.RoleResource {
id, _ := uuidv7.BytesToString(role.ID)
return dto.RoleResource{
Type: "role",
ID: id,
Attributes: dto.RoleAttributes{
Name: role.Name,
Description: role.Description,
CreatedAt: role.CreatedAt.UTC().Format(time.RFC3339),
UpdatedAt: role.UpdatedAt.UTC().Format(time.RFC3339),
},
}
}
func parseIntDefault(v string, def int) int {
if v == "" {
return def
}
n, err := strconv.Atoi(v)
if err != nil {
return def
}
return n
}
package handlers
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/domain/user"
"wucher/internal/transport/http/response"
"wucher/internal/transport/http/jsonapi"
)
type UserHandler struct {
svc user.Service
}
func NewUserHandler(svc user.Service) *UserHandler {
return &UserHandler{svc: svc}
}
func (h *UserHandler) List(c *fiber.Ctx) error {
return response.WriteErrors(c, fiber.StatusNotImplemented, []jsonapi.ErrorObject{{
Status: "501",
Title: "Not implemented",
}})
}
func (h *UserHandler) Get(c *fiber.Ctx) error {
return response.WriteErrors(c, fiber.StatusNotImplemented, []jsonapi.ErrorObject{{
Status: "501",
Title: "Not implemented",
}})
}
package jsonapi
import "github.com/gofiber/fiber/v2"
type Resource struct {
Type string `json:"type"`
ID string `json:"id,omitempty"`
Attributes map[string]any `json:"attributes,omitempty"`
}
type Document struct {
Data any `json:"data"`
Meta any `json:"meta,omitempty"`
}
type ErrorSource struct {
Pointer string `json:"pointer,omitempty"`
}
type ErrorObject struct {
Status string `json:"status"`
Code string `json:"code,omitempty"`
Title string `json:"title,omitempty"`
Detail string `json:"detail,omitempty"`
Source *ErrorSource `json:"source,omitempty"`
}
type ErrorDocument struct {
Errors []ErrorObject `json:"errors"`
}
func Write(c *fiber.Ctx, status int, data any) error {
return c.Status(status).JSON(Document{Data: data})
}
func WriteWithMeta(c *fiber.Ctx, status int, data any, meta any) error {
return c.Status(status).JSON(Document{Data: data, Meta: meta})
}
func WriteErrors(c *fiber.Ctx, status int, errs []ErrorObject) error {
return c.Status(status).JSON(ErrorDocument{Errors: errs})
}
package middleware
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/jsonapi"
"wucher/internal/transport/http/response"
)
func AuthRequired(svc *service.AuthService) fiber.Handler {
return func(c *fiber.Ctx) error {
if svc == nil {
return response.WriteErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
Status: "401",
Title: "Unauthorized",
Detail: "auth service not configured",
}})
}
token := c.Cookies(svc.AccessCookieName())
userID, err := svc.ParseAccessToken(c.Context(), token)
if err != nil {
return response.WriteErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
Status: "401",
Title: "Unauthorized",
Detail: "invalid or expired token",
}})
}
c.Locals("user_id", userID)
return c.Next()
}
}
package validators
import (
"fmt"
"reflect"
"strings"
"github.com/go-playground/validator/v10"
"wucher/internal/transport/http/jsonapi"
)
type Validator struct {
validate *validator.Validate
}
func New() *Validator {
v := validator.New()
v.RegisterTagNameFunc(func(fld reflect.StructField) string {
name := fld.Tag.Get("json")
if name == "" {
return fld.Name
}
if idx := strings.Index(name, ","); idx != -1 {
return name[:idx]
}
return name
})
return &Validator{validate: v}
}
func (v *Validator) ValidateStruct(s any) []jsonapi.ErrorObject {
if err := v.validate.Struct(s); err != nil {
if ve, ok := err.(validator.ValidationErrors); ok {
return toJSONAPIErrors(ve)
}
return []jsonapi.ErrorObject{{
Status: "400",
Title: "Invalid request",
Detail: err.Error(),
}}
}
return nil
}
func toJSONAPIErrors(ve validator.ValidationErrors) []jsonapi.ErrorObject {
errs := make([]jsonapi.ErrorObject, 0, len(ve))
for _, fe := range ve {
field := fe.Field()
jsonPath := "/data/attributes/" + strings.ReplaceAll(field, ".", "/")
errs = append(errs, jsonapi.ErrorObject{
Status: "422",
Title: "Validation error",
Detail: fmt.Sprintf("%s is %s", field, fe.Tag()),
Source: &jsonapi.ErrorSource{Pointer: jsonPath},
})
}
return errs
}