Files
fm_be/coverage.html
2026-07-16 22:16:45 +07:00

5166 lines
192 KiB
HTML

<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<title>api: Go Coverage Report</title>
<style>
body {
background: black;
color: rgb(80, 80, 80);
}
body, pre, #legend span {
font-family: Menlo, monospace;
font-weight: bold;
}
#topbar {
background: black;
position: fixed;
top: 0; left: 0; right: 0;
height: 42px;
border-bottom: 1px solid rgb(80, 80, 80);
}
#content {
margin-top: 50px;
}
#nav, #legend {
float: left;
margin-left: 10px;
}
#legend {
margin-top: 12px;
}
#nav {
margin-top: 10px;
}
#legend span {
margin: 0 5px;
}
.cov0 { color: rgb(192, 0, 0) }
.cov1 { color: rgb(128, 128, 128) }
.cov2 { color: rgb(116, 140, 131) }
.cov3 { color: rgb(104, 152, 134) }
.cov4 { color: rgb(92, 164, 137) }
.cov5 { color: rgb(80, 176, 140) }
.cov6 { color: rgb(68, 188, 143) }
.cov7 { color: rgb(56, 200, 146) }
.cov8 { color: rgb(44, 212, 149) }
.cov9 { color: rgb(32, 224, 152) }
.cov10 { color: rgb(20, 236, 155) }
</style>
</head>
<body>
<div id="topbar">
<div id="nav">
<select id="files">
<option value="file0">wucher/cmd/api/main.go (0.0%)</option>
<option value="file1">wucher/cmd/worker/main.go (0.0%)</option>
<option value="file2">wucher/docs/docs.go (0.0%)</option>
<option value="file3">wucher/internal/app/api/middleware.go (0.0%)</option>
<option value="file4">wucher/internal/app/api/routes.go (0.0%)</option>
<option value="file5">wucher/internal/app/api/routes_auth.go (0.0%)</option>
<option value="file6">wucher/internal/app/api/routes_role.go (0.0%)</option>
<option value="file7">wucher/internal/app/api/routes_swagger.go (0.0%)</option>
<option value="file8">wucher/internal/app/api/routes_user.go (0.0%)</option>
<option value="file9">wucher/internal/app/api/seed.go (0.0%)</option>
<option value="file10">wucher/internal/app/api/server.go (0.0%)</option>
<option value="file11">wucher/internal/app/worker/runner.go (0.0%)</option>
<option value="file12">wucher/internal/config/config.go (0.0%)</option>
<option value="file13">wucher/internal/domain/auth/identity.go (0.0%)</option>
<option value="file14">wucher/internal/domain/auth/permission.go (0.0%)</option>
<option value="file15">wucher/internal/domain/auth/role.go (0.0%)</option>
<option value="file16">wucher/internal/domain/auth/role_permission.go (0.0%)</option>
<option value="file17">wucher/internal/domain/auth/totp.go (0.0%)</option>
<option value="file18">wucher/internal/domain/auth/user.go (0.0%)</option>
<option value="file19">wucher/internal/pkg/logger/logger.go (0.0%)</option>
<option value="file20">wucher/internal/pkg/uuidv7/uuidv7.go (0.0%)</option>
<option value="file21">wucher/internal/repository/mysql/auth_repo.go (0.0%)</option>
<option value="file22">wucher/internal/repository/mysql/db.go (0.0%)</option>
<option value="file23">wucher/internal/repository/mysql/role_repo.go (0.0%)</option>
<option value="file24">wucher/internal/repository/mysql/user_repo.go (0.0%)</option>
<option value="file25">wucher/internal/repository/redis/email_queue.go (0.0%)</option>
<option value="file26">wucher/internal/repository/redis/redis.go (0.0%)</option>
<option value="file27">wucher/internal/repository/redis/token_store.go (0.0%)</option>
<option value="file28">wucher/internal/service/auth_service.go (39.2%)</option>
<option value="file29">wucher/internal/service/email_queue.go (0.0%)</option>
<option value="file30">wucher/internal/service/microsoft_sso.go (0.0%)</option>
<option value="file31">wucher/internal/service/role_service.go (0.0%)</option>
<option value="file32">wucher/internal/service/secret_protector.go (70.0%)</option>
<option value="file33">wucher/internal/service/smtp_sender.go (0.0%)</option>
<option value="file34">wucher/internal/service/user_service.go (0.0%)</option>
<option value="file35">wucher/internal/transport/http/handlers/auth_handler.go (6.0%)</option>
<option value="file36">wucher/internal/transport/http/handlers/health_handler.go (0.0%)</option>
<option value="file37">wucher/internal/transport/http/handlers/role_handler.go (0.0%)</option>
<option value="file38">wucher/internal/transport/http/handlers/user_handler.go (0.0%)</option>
<option value="file39">wucher/internal/transport/http/jsonapi/jsonapi.go (0.0%)</option>
<option value="file40">wucher/internal/transport/http/middleware/auth_middleware.go (0.0%)</option>
<option value="file41">wucher/internal/transport/http/validators/validator.go (0.0%)</option>
</select>
</div>
<div id="legend">
<span>not tracked</span>
<span class="cov0">not covered</span>
<span class="cov8">covered</span>
</div>
</div>
<div id="content">
<pre class="file" id="file0" style="display: none">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) -&gt; cookies
// @description
// @description Auth Flow (TOTP setup):
// @description 1) POST /api/v1/auth/totp/setup -&gt; secret + otpauth_url (enabled=false)
// @description 2) POST /api/v1/auth/totp/confirm -&gt; enabled=true
// @description
// @description Auth Flow (Microsoft Entra SSO):
// @description 1) GET /api/v1/auth/microsoft/login -&gt; 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() <span class="cov0" title="0">{
_ = godotenv.Load()
cfg := config.LoadMySQLConfigFromEnv()
db, err := mysql.Connect(cfg)
if err != nil </span><span class="cov0" title="0">{
log.Fatal(err)
}</span>
<span class="cov0" title="0">if err := mysql.AutoMigrate(db); err != nil </span><span class="cov0" title="0">{
log.Fatal(err)
}</span>
<span class="cov0" title="0">if err := api.SeedRoles(db); err != nil </span><span class="cov0" title="0">{
log.Fatal(err)
}</span>
<span class="cov0" title="0">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 != "" &amp;&amp; msCfg.ClientID != "" &amp;&amp; msCfg.ClientSecret != "" &amp;&amp; msCfg.RedirectURL != "" </span><span class="cov0" title="0">{
msSSO = service.NewMicrosoftSSO(msCfg)
}</span>
<span class="cov0" title="0">authRepo := mysql.NewAuthRepository(db)
var protector service.SecretProtector
if p, err := service.NewAESGCMProtectorFromBase64(authCfg.TOTPSecretKeyB64); err == nil </span><span class="cov0" title="0">{
protector = p
}</span>
<span class="cov0" title="0">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 != "" </span><span class="cov0" title="0">{
addr = ":" + v
}</span>
<span class="cov0" title="0">srv := api.NewServer(app)
log.Printf("API listening on %s", addr)
if err := srv.Start(addr); err != nil </span><span class="cov0" title="0">{
log.Fatal(err)
}</span>
}
</pre>
<pre class="file" id="file1" style="display: none">package main
import (
"context"
"log"
"os/signal"
"syscall"
"github.com/joho/godotenv"
"wucher/internal/config"
"wucher/internal/repository/redis"
"wucher/internal/service"
)
func main() <span class="cov0" title="0">{
_ = 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 </span><span class="cov0" title="0">{
select </span>{
case &lt;-ctx.Done():<span class="cov0" title="0">
log.Println("worker stopped")
return</span>
default:<span class="cov0" title="0">
job, err := queue.Dequeue(ctx)
if err != nil </span><span class="cov0" title="0">{
log.Printf("queue error: %v", err)
continue</span>
}
<span class="cov0" title="0">if job == nil </span><span class="cov0" title="0">{
continue</span>
}
<span class="cov0" title="0">if err := mailer.Send(ctx, job.To, job.Subject, job.Body); err != nil </span><span class="cov0" title="0">{
log.Printf("send email failed: %v", err)
}</span>
}
}
}
</pre>
<pre class="file" id="file2" style="display: none">// 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 = &amp;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) -&gt; cookies\n\nAuth Flow (TOTP setup):\n1) POST /api/v1/auth/totp/setup -&gt; secret + otpauth_url (enabled=false)\n2) POST /api/v1/auth/totp/confirm -&gt; enabled=true\n\nAuth Flow (Microsoft Entra SSO):\n1) GET /api/v1/auth/microsoft/login -&gt; 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() <span class="cov0" title="0">{
swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo)
}</span>
</pre>
<pre class="file" id="file3" style="display: none">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) <span class="cov0" title="0">{
app.Use(recover.New())
app.Use(requestid.New())
app.Use(logger.New())
}</span>
</pre>
<pre class="file" id="file4" style="display: none">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,
) <span class="cov0" title="0">{
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)
}</span>
</pre>
<pre class="file" id="file5" style="display: none">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) <span class="cov0" title="0">{
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)
}</span>
</pre>
<pre class="file" id="file6" style="display: none">package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/transport/http/handlers"
)
func registerRoleRoutes(v1 fiber.Router, roleHandler *handlers.RoleHandler, authMiddleware fiber.Handler) <span class="cov0" title="0">{
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)
}</span>
</pre>
<pre class="file" id="file7" style="display: none">package api
import (
"github.com/gofiber/fiber/v2"
"github.com/gofiber/swagger"
)
func registerSwaggerRoutes(app *fiber.App) <span class="cov0" title="0">{
app.Get("/swagger/*", swagger.HandlerDefault)
}</span>
</pre>
<pre class="file" id="file8" style="display: none">package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/transport/http/handlers"
)
func registerUserRoutes(v1 fiber.Router, userHandler *handlers.UserHandler) <span class="cov0" title="0">{
users := v1.Group("/users")
users.Get("/", userHandler.List)
users.Get("/:id", userHandler.Get)
}</span>
</pre>
<pre class="file" id="file9" style="display: none">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 <span class="cov0" title="0">{
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(&amp;roles).Error
}</span>
</pre>
<pre class="file" id="file10" style="display: none">package api
import (
"context"
"time"
"github.com/gofiber/fiber/v2"
)
type Server struct {
app *fiber.App
}
func NewServer(app *fiber.App) *Server <span class="cov0" title="0">{
return &amp;Server{
app: app,
}
}</span>
func (s *Server) Start(addr string) error <span class="cov0" title="0">{
return s.app.Listen(addr)
}</span>
func (s *Server) Shutdown(ctx context.Context) error <span class="cov0" title="0">{
shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
return s.app.ShutdownWithContext(shutdownCtx)
}</span>
</pre>
<pre class="file" id="file11" style="display: none">package worker
type Runner struct{}
func NewRunner() *Runner <span class="cov0" title="0">{
return &amp;Runner{}
}</span>
func (r *Runner) Start() error <span class="cov0" title="0">{
return nil
}</span>
</pre>
<pre class="file" id="file12" style="display: none">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 <span class="cov0" title="0">{
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),
}
}</span>
func LoadAuthConfigFromEnv() AuthConfig <span class="cov0" title="0">{
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",
}
}</span>
func LoadSMTPConfigFromEnv() SMTPConfig <span class="cov0" title="0">{
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",
}
}</span>
func LoadMicrosoftSSOConfigFromEnv() MicrosoftSSOConfig <span class="cov0" title="0">{
scopes := strings.Split(getenv("MS_ENTRA_SCOPES", "openid,profile,email"), ",")
for i := range scopes </span><span class="cov0" title="0">{
scopes[i] = strings.TrimSpace(scopes[i])
}</span>
<span class="cov0" title="0">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,
}</span>
}
func LoadRedisConfigFromEnv() RedisConfig <span class="cov0" title="0">{
return RedisConfig{
Addr: getenv("REDIS_ADDR", ""),
Password: getenv("REDIS_PASSWORD", ""),
DB: getenvInt("REDIS_DB", 0),
}
}</span>
func getenv(key, def string) string <span class="cov0" title="0">{
if v := os.Getenv(key); v != "" </span><span class="cov0" title="0">{
return v
}</span>
<span class="cov0" title="0">return def</span>
}
func getenvInt(key string, def int) int <span class="cov0" title="0">{
if v := os.Getenv(key); v != "" </span><span class="cov0" title="0">{
if n, err := strconv.Atoi(v); err == nil </span><span class="cov0" title="0">{
return n
}</span>
}
<span class="cov0" title="0">return def</span>
}
func getenvDuration(key string, def time.Duration) time.Duration <span class="cov0" title="0">{
if v := os.Getenv(key); v != "" </span><span class="cov0" title="0">{
if d, err := time.ParseDuration(v); err == nil </span><span class="cov0" title="0">{
return d
}</span>
}
<span class="cov0" title="0">return def</span>
}
</pre>
<pre class="file" id="file13" style="display: none">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 <span class="cov0" title="0">{ return "user_identities" }</span>
func (ui *UserIdentity) BeforeCreate(tx *gorm.DB) error <span class="cov0" title="0">{
if len(ui.ID) == 0 </span><span class="cov0" title="0">{
ui.ID = uuidv7.MustBytes()
}</span>
<span class="cov0" title="0">return nil</span>
}
</pre>
<pre class="file" id="file14" style="display: none">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 <span class="cov0" title="0">{ return "permissions" }</span>
func (p *Permission) BeforeCreate(tx *gorm.DB) error <span class="cov0" title="0">{
if len(p.ID) == 0 </span><span class="cov0" title="0">{
p.ID = uuidv7.MustBytes()
}</span>
<span class="cov0" title="0">return nil</span>
}
</pre>
<pre class="file" id="file15" style="display: none">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 <span class="cov0" title="0">{ return "roles" }</span>
func (r *Role) BeforeCreate(tx *gorm.DB) error <span class="cov0" title="0">{
if len(r.ID) == 0 </span><span class="cov0" title="0">{
r.ID = uuidv7.MustBytes()
}</span>
<span class="cov0" title="0">return nil</span>
}
</pre>
<pre class="file" id="file16" style="display: none">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 <span class="cov0" title="0">{ return "role_permissions" }</span>
func (rp *RolePermission) BeforeCreate(tx *gorm.DB) error <span class="cov0" title="0">{
if len(rp.ID) == 0 </span><span class="cov0" title="0">{
rp.ID = uuidv7.MustBytes()
}</span>
<span class="cov0" title="0">return nil</span>
}
</pre>
<pre class="file" id="file17" style="display: none">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 <span class="cov0" title="0">{ return "user_totp" }</span>
func (ut *UserTOTP) BeforeCreate(tx *gorm.DB) error <span class="cov0" title="0">{
if len(ut.ID) == 0 </span><span class="cov0" title="0">{
ut.ID = uuidv7.MustBytes()
}</span>
<span class="cov0" title="0">return nil</span>
}
</pre>
<pre class="file" id="file18" style="display: none">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 <span class="cov0" title="0">{ return "users" }</span>
func (u *User) BeforeCreate(tx *gorm.DB) error <span class="cov0" title="0">{
if len(u.ID) == 0 </span><span class="cov0" title="0">{
u.ID = uuidv7.MustBytes()
}</span>
<span class="cov0" title="0">return nil</span>
}
</pre>
<pre class="file" id="file19" style="display: none">package logger
import "log"
func New() *log.Logger <span class="cov0" title="0">{
return log.Default()
}</span>
</pre>
<pre class="file" id="file20" style="display: none">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) <span class="cov0" title="0">{
var out [16]byte
var r [10]byte
if _, err := rand.Read(r[:]); err != nil </span><span class="cov0" title="0">{
return out, err
}</span>
// 48-bit Unix epoch milliseconds
<span class="cov0" title="0">ms := uint64(time.Now().UnixNano() / int64(time.Millisecond))
out[0] = byte(ms &gt;&gt; 40)
out[1] = byte(ms &gt;&gt; 32)
out[2] = byte(ms &gt;&gt; 24)
out[3] = byte(ms &gt;&gt; 16)
out[4] = byte(ms &gt;&gt; 8)
out[5] = byte(ms)
// Version (0111) in high 4 bits of byte 6
out[6] = (r[0] &amp; 0x0f) | 0x70
out[7] = r[1]
// Variant (10) in high 2 bits of byte 8
out[8] = (r[2] &amp; 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</span>
}
// MustNew panics if UUID generation fails.
func MustNew() [16]byte <span class="cov0" title="0">{
u, err := New()
if err != nil </span><span class="cov0" title="0">{
panic(err)</span>
}
<span class="cov0" title="0">return u</span>
}
// Bytes returns a new UUIDv7 as []byte for BINARY(16) columns.
func Bytes() ([]byte, error) <span class="cov0" title="0">{
u, err := New()
if err != nil </span><span class="cov0" title="0">{
return nil, err
}</span>
<span class="cov0" title="0">b := make([]byte, 16)
copy(b, u[:])
return b, nil</span>
}
// MustBytes panics if UUID generation fails.
func MustBytes() []byte <span class="cov0" title="0">{
u := MustNew()
b := make([]byte, 16)
copy(b, u[:])
return b
}</span>
// String formats a UUID as a canonical string (8-4-4-4-12).
func String(u [16]byte) (string, error) <span class="cov0" title="0">{
if len(u) != 16 </span><span class="cov0" title="0">{
return "", errors.New("invalid uuid length")
}</span>
<span class="cov0" title="0">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</span>
}
// BytesToString formats a UUID stored in []byte as a canonical string.
func BytesToString(b []byte) (string, error) <span class="cov0" title="0">{
if len(b) != 16 </span><span class="cov0" title="0">{
return "", errors.New("invalid uuid length")
}</span>
<span class="cov0" title="0">var u [16]byte
copy(u[:], b)
return String(u)</span>
}
// ParseString parses a canonical UUID string into []byte (16 bytes).
func ParseString(s string) ([]byte, error) <span class="cov0" title="0">{
if len(s) != 36 </span><span class="cov0" title="0">{
return nil, errors.New("invalid uuid string length")
}</span>
<span class="cov0" title="0">buf := make([]byte, 16)
_, err := hex.Decode(buf[0:4], []byte(s[0:8]))
if err != nil </span><span class="cov0" title="0">{
return nil, err
}</span>
<span class="cov0" title="0">_, err = hex.Decode(buf[4:6], []byte(s[9:13]))
if err != nil </span><span class="cov0" title="0">{
return nil, err
}</span>
<span class="cov0" title="0">_, err = hex.Decode(buf[6:8], []byte(s[14:18]))
if err != nil </span><span class="cov0" title="0">{
return nil, err
}</span>
<span class="cov0" title="0">_, err = hex.Decode(buf[8:10], []byte(s[19:23]))
if err != nil </span><span class="cov0" title="0">{
return nil, err
}</span>
<span class="cov0" title="0">_, err = hex.Decode(buf[10:16], []byte(s[24:36]))
if err != nil </span><span class="cov0" title="0">{
return nil, err
}</span>
<span class="cov0" title="0">return buf, nil</span>
}
</pre>
<pre class="file" id="file21" style="display: none">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 <span class="cov0" title="0">{
return &amp;AuthRepository{db: db}
}</span>
// Users
func (r *AuthRepository) CreateUser(ctx context.Context, user *auth.User) error <span class="cov0" title="0">{
return r.db.WithContext(ctx).Create(user).Error
}</span>
func (r *AuthRepository) GetUserByID(ctx context.Context, id []byte) (*auth.User, error) <span class="cov0" title="0">{
var user auth.User
err := r.db.WithContext(ctx).First(&amp;user, "id = ?", id).Error
if errors.Is(err, gorm.ErrRecordNotFound) </span><span class="cov0" title="0">{
return nil, nil
}</span>
<span class="cov0" title="0">return &amp;user, err</span>
}
func (r *AuthRepository) GetUserByEmail(ctx context.Context, email string) (*auth.User, error) <span class="cov0" title="0">{
var user auth.User
err := r.db.WithContext(ctx).Where("email = ?", email).First(&amp;user).Error
if errors.Is(err, gorm.ErrRecordNotFound) </span><span class="cov0" title="0">{
return nil, nil
}</span>
<span class="cov0" title="0">return &amp;user, err</span>
}
func (r *AuthRepository) MarkEmailVerified(ctx context.Context, id []byte) error <span class="cov0" title="0">{
now := time.Now()
return r.db.WithContext(ctx).
Model(&amp;auth.User{}).
Where("id = ?", id).
Update("email_verified_at", &amp;now).Error
}</span>
// Identities (SSO)
func (r *AuthRepository) UpsertIdentity(ctx context.Context, identity *auth.UserIdentity) error <span class="cov0" title="0">{
// 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
}</span>
func (r *AuthRepository) GetIdentityByProviderSubject(ctx context.Context, provider, subject string) (*auth.UserIdentity, error) <span class="cov0" title="0">{
var identity auth.UserIdentity
err := r.db.WithContext(ctx).
Where("provider = ? AND provider_subject = ?", provider, subject).
First(&amp;identity).Error
if errors.Is(err, gorm.ErrRecordNotFound) </span><span class="cov0" title="0">{
return nil, nil
}</span>
<span class="cov0" title="0">return &amp;identity, err</span>
}
// TOTP
func (r *AuthRepository) GetUserTOTP(ctx context.Context, userID []byte) (*auth.UserTOTP, error) <span class="cov0" title="0">{
var totp auth.UserTOTP
err := r.db.WithContext(ctx).Where("user_id = ?", userID).First(&amp;totp).Error
if errors.Is(err, gorm.ErrRecordNotFound) </span><span class="cov0" title="0">{
return nil, nil
}</span>
<span class="cov0" title="0">return &amp;totp, err</span>
}
func (r *AuthRepository) UpsertUserTOTP(ctx context.Context, totp *auth.UserTOTP) error <span class="cov0" title="0">{
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
}</span>
func (r *AuthRepository) DisableUserTOTP(ctx context.Context, userID []byte) error <span class="cov0" title="0">{
return r.db.WithContext(ctx).
Model(&amp;auth.UserTOTP{}).
Where("user_id = ?", userID).
Updates(map[string]any{
"enabled": false,
"updated_at": time.Now(),
}).Error
}</span>
func (r *AuthRepository) UpdateUserTOTPLastUsed(ctx context.Context, userID []byte) error <span class="cov0" title="0">{
return r.db.WithContext(ctx).
Model(&amp;auth.UserTOTP{}).
Where("user_id = ? AND enabled = 1", userID).
Update("last_used_at", time.Now()).Error
}</span>
</pre>
<pre class="file" id="file22" style="display: none">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) <span class="cov0" title="0">{
if cfg.DSN == "" </span><span class="cov0" title="0">{
return nil, errors.New("MYSQL_DSN is required")
}</span>
<span class="cov0" title="0">db, err := gorm.Open(mysql.Open(cfg.DSN), &amp;gorm.Config{
Logger: logger.Default.LogMode(logger.Warn),
})
if err != nil </span><span class="cov0" title="0">{
return nil, err
}</span>
<span class="cov0" title="0">sqlDB, err := db.DB()
if err != nil </span><span class="cov0" title="0">{
return nil, err
}</span>
<span class="cov0" title="0">sqlDB.SetMaxOpenConns(cfg.MaxOpenConns)
sqlDB.SetMaxIdleConns(cfg.MaxIdleConns)
sqlDB.SetConnMaxLifetime(cfg.ConnMaxLifetime)
sqlDB.SetConnMaxIdleTime(cfg.ConnMaxIdleTime)
return db, nil</span>
}
func AutoMigrate(db *gorm.DB) error <span class="cov0" title="0">{
return db.AutoMigrate(
&amp;auth.Role{},
&amp;auth.Permission{},
&amp;auth.User{},
&amp;auth.RolePermission{},
&amp;auth.UserIdentity{},
&amp;auth.UserTOTP{},
)
}</span>
</pre>
<pre class="file" id="file23" style="display: none">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 <span class="cov0" title="0">{
return &amp;RoleRepository{db: db}
}</span>
func (r *RoleRepository) CreateRole(ctx context.Context, role *auth.Role) error <span class="cov0" title="0">{
return r.db.WithContext(ctx).Create(role).Error
}</span>
func (r *RoleRepository) UpdateRole(ctx context.Context, role *auth.Role) error <span class="cov0" title="0">{
return r.db.WithContext(ctx).Save(role).Error
}</span>
func (r *RoleRepository) DeleteRole(ctx context.Context, id []byte) error <span class="cov0" title="0">{
return r.db.WithContext(ctx).Delete(&amp;auth.Role{}, "id = ?", id).Error
}</span>
func (r *RoleRepository) GetRoleByID(ctx context.Context, id []byte) (*auth.Role, error) <span class="cov0" title="0">{
var role auth.Role
err := r.db.WithContext(ctx).First(&amp;role, "id = ?", id).Error
if errors.Is(err, gorm.ErrRecordNotFound) </span><span class="cov0" title="0">{
return nil, nil
}</span>
<span class="cov0" title="0">return &amp;role, err</span>
}
func (r *RoleRepository) GetRoleByName(ctx context.Context, name string) (*auth.Role, error) <span class="cov0" title="0">{
var role auth.Role
err := r.db.WithContext(ctx).Where("name = ?", name).First(&amp;role).Error
if errors.Is(err, gorm.ErrRecordNotFound) </span><span class="cov0" title="0">{
return nil, nil
}</span>
<span class="cov0" title="0">return &amp;role, err</span>
}
func (r *RoleRepository) ListRoles(ctx context.Context, filterName, sort string, limit, offset int) ([]auth.Role, int64, error) <span class="cov0" title="0">{
var roles []auth.Role
var total int64
query := r.db.WithContext(ctx).Model(&amp;auth.Role{})
if filterName != "" </span><span class="cov0" title="0">{
query = query.Where("LOWER(name) LIKE ?", "%"+strings.ToLower(filterName)+"%")
}</span>
<span class="cov0" title="0">if err := query.Count(&amp;total).Error; err != nil </span><span class="cov0" title="0">{
return nil, 0, err
}</span>
<span class="cov0" title="0">if sort != "" </span><span class="cov0" title="0">{
query = query.Order(sort)
}</span> else<span class="cov0" title="0"> {
query = query.Order("created_at DESC")
}</span>
<span class="cov0" title="0">if limit &gt; 0 </span><span class="cov0" title="0">{
query = query.Limit(limit).Offset(offset)
}</span>
<span class="cov0" title="0">if err := query.Find(&amp;roles).Error; err != nil </span><span class="cov0" title="0">{
return nil, 0, err
}</span>
<span class="cov0" title="0">return roles, total, nil</span>
}
</pre>
<pre class="file" id="file24" style="display: none">package mysql
import (
"context"
"errors"
"gorm.io/gorm"
"wucher/internal/domain/user"
)
type UserRepository struct {
db *gorm.DB
}
func NewUserRepository(db *gorm.DB) *UserRepository <span class="cov0" title="0">{
return &amp;UserRepository{db: db}
}</span>
func (r *UserRepository) Create(ctx context.Context, u *user.User) error <span class="cov0" title="0">{
return r.db.WithContext(ctx).Create(u).Error
}</span>
func (r *UserRepository) GetByID(ctx context.Context, id []byte) (*user.User, error) <span class="cov0" title="0">{
var u user.User
err := r.db.WithContext(ctx).First(&amp;u, "id = ?", id).Error
if errors.Is(err, gorm.ErrRecordNotFound) </span><span class="cov0" title="0">{
return nil, nil
}</span>
<span class="cov0" title="0">return &amp;u, err</span>
}
func (r *UserRepository) GetByEmail(ctx context.Context, email string) (*user.User, error) <span class="cov0" title="0">{
var u user.User
err := r.db.WithContext(ctx).Where("email = ?", email).First(&amp;u).Error
if errors.Is(err, gorm.ErrRecordNotFound) </span><span class="cov0" title="0">{
return nil, nil
}</span>
<span class="cov0" title="0">return &amp;u, err</span>
}
</pre>
<pre class="file" id="file25" style="display: none">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 <span class="cov0" title="0">{
return &amp;EmailQueue{client: client, key: key}
}</span>
func (q *EmailQueue) Enqueue(ctx context.Context, job service.EmailJob) error <span class="cov0" title="0">{
b, err := service.EncodeEmailJob(job)
if err != nil </span><span class="cov0" title="0">{
return err
}</span>
<span class="cov0" title="0">return q.client.RPush(ctx, q.key, b).Err()</span>
}
func (q *EmailQueue) Dequeue(ctx context.Context) (*service.EmailJob, error) <span class="cov0" title="0">{
res, err := q.client.BLPop(ctx, 10*time.Second, q.key).Result()
if err == redis.Nil </span><span class="cov0" title="0">{
return nil, nil
}</span>
<span class="cov0" title="0">if err != nil </span><span class="cov0" title="0">{
return nil, err
}</span>
<span class="cov0" title="0">if len(res) &lt; 2 </span><span class="cov0" title="0">{
return nil, nil
}</span>
<span class="cov0" title="0">return service.DecodeEmailJob([]byte(res[1]))</span>
}
</pre>
<pre class="file" id="file26" style="display: none">package redis
import (
"context"
"github.com/redis/go-redis/v9"
"wucher/internal/config"
)
func NewClient(cfg config.RedisConfig) *redis.Client <span class="cov0" title="0">{
return redis.NewClient(&amp;redis.Options{
Addr: cfg.Addr,
Password: cfg.Password,
DB: cfg.DB,
})
}</span>
func Ping(ctx context.Context, client *redis.Client) error <span class="cov0" title="0">{
return client.Ping(ctx).Err()
}</span>
</pre>
<pre class="file" id="file27" style="display: none">package redis
import (
"context"
"time"
"github.com/redis/go-redis/v9"
)
type TokenStore struct {
client *redis.Client
}
func NewTokenStore(client *redis.Client) *TokenStore <span class="cov0" title="0">{
return &amp;TokenStore{client: client}
}</span>
func (s *TokenStore) Set(ctx context.Context, key string, value []byte, ttl time.Duration) error <span class="cov0" title="0">{
return s.client.Set(ctx, key, value, ttl).Err()
}</span>
func (s *TokenStore) Get(ctx context.Context, key string) ([]byte, error) <span class="cov0" title="0">{
val, err := s.client.Get(ctx, key).Bytes()
if err == redis.Nil </span><span class="cov0" title="0">{
return nil, nil
}</span>
<span class="cov0" title="0">return val, err</span>
}
func (s *TokenStore) Delete(ctx context.Context, key string) error <span class="cov0" title="0">{
return s.client.Del(ctx, key).Err()
}</span>
</pre>
<pre class="file" id="file28" style="display: none">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 <span class="cov8" title="1">{
return &amp;AuthService{
repo: repo,
roleRepo: roleRepo,
tokens: tokens,
emailQueue: emailQueue,
mailer: mailer,
sso: sso,
protector: protector,
cfg: cfg,
}
}</span>
func (s *AuthService) BeginMicrosoftLogin(ctx context.Context) (string, error) <span class="cov0" title="0">{
if s.sso == nil || s.tokens == nil </span><span class="cov0" title="0">{
return "", errors.New("sso not configured")
}</span>
<span class="cov0" title="0">state, err := randomToken(32)
if err != nil </span><span class="cov0" title="0">{
return "", err
}</span>
<span class="cov0" title="0">if err := s.tokens.Set(ctx, ssoStateKey(state), []byte("1"), s.cfg.SSOStateTTL); err != nil </span><span class="cov0" title="0">{
return "", err
}</span>
<span class="cov0" title="0">return s.sso.AuthCodeURL(state), nil</span>
}
func (s *AuthService) CompleteMicrosoftLogin(ctx context.Context, code, state string) (*auth.UserIdentity, error) <span class="cov0" title="0">{
if s.sso == nil || s.tokens == nil </span><span class="cov0" title="0">{
return nil, errors.New("sso not configured")
}</span>
<span class="cov0" title="0">if state == "" </span><span class="cov0" title="0">{
return nil, ErrInvalidToken
}</span>
<span class="cov0" title="0">v, err := s.tokens.Get(ctx, ssoStateKey(state))
if err != nil || v == nil </span><span class="cov0" title="0">{
return nil, ErrInvalidToken
}</span>
<span class="cov0" title="0">_ = s.tokens.Delete(ctx, ssoStateKey(state))
return s.LoginWithMicrosoft(ctx, code)</span>
}
// 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) <span class="cov8" title="1">{
email = strings.ToLower(strings.TrimSpace(email))
if email == "" || password == "" </span><span class="cov0" title="0">{
return nil, errors.New("email and password are required")
}</span>
<span class="cov8" title="1">hash, salt, err := s.hashPassword(password)
if err != nil </span><span class="cov0" title="0">{
return nil, err
}</span>
<span class="cov8" title="1">user := &amp;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 </span><span class="cov0" title="0">{
return nil, err
}</span>
<span class="cov8" title="1">if err := s.sendEmailVerification(ctx, user); err != nil </span><span class="cov0" title="0">{
return nil, err
}</span>
<span class="cov8" title="1">return user, nil</span>
}
func (s *AuthService) GetRoleIDByName(ctx context.Context, name string) ([]byte, error) <span class="cov0" title="0">{
if s.roleRepo == nil </span><span class="cov0" title="0">{
return nil, errors.New("role repo not configured")
}</span>
<span class="cov0" title="0">role, err := s.roleRepo.GetRoleByName(ctx, name)
if err != nil || role == nil </span><span class="cov0" title="0">{
return nil, errors.New("role not found")
}</span>
<span class="cov0" title="0">return role.ID, nil</span>
}
func (s *AuthService) DefaultRoleName() string <span class="cov0" title="0">{ return s.cfg.DefaultRoleName }</span>
func (s *AuthService) RegisterDisabled() bool <span class="cov0" title="0">{ return s.cfg.DisableRegister }</span>
// LoginWithEmailPassword verifies email/password and returns the user.
func (s *AuthService) LoginWithEmailPassword(ctx context.Context, email, password string) (*auth.User, error) <span class="cov8" title="1">{
email = strings.ToLower(strings.TrimSpace(email))
user, err := s.repo.GetUserByEmail(ctx, email)
if err != nil || user == nil </span><span class="cov0" title="0">{
return nil, ErrInvalidCredentials
}</span>
<span class="cov8" title="1">if !s.verifyPassword(password, user.PasswordHash, user.SaltValue) </span><span class="cov0" title="0">{
return nil, ErrInvalidCredentials
}</span>
<span class="cov8" title="1">if user.EmailVerifiedAt == nil </span><span class="cov8" title="1">{
return nil, ErrEmailNotVerified
}</span>
<span class="cov8" title="1">return user, nil</span>
}
func (s *AuthService) IsTOTPEnabled(ctx context.Context, userID []byte) (bool, error) <span class="cov8" title="1">{
record, err := s.repo.GetUserTOTP(ctx, userID)
if err != nil || record == nil </span><span class="cov0" title="0">{
return false, err
}</span>
<span class="cov8" title="1">return record.Enabled, nil</span>
}
type TokenPair struct {
AccessToken string
RefreshToken string
}
func (s *AuthService) IssueTokens(ctx context.Context, user *auth.User) (TokenPair, error) <span class="cov8" title="1">{
if s.cfg.JWTAccessSecret == "" || s.cfg.JWTRefreshSecret == "" </span><span class="cov0" title="0">{
return TokenPair{}, errors.New("jwt secrets not configured")
}</span>
<span class="cov8" title="1">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 </span><span class="cov0" title="0">{
return TokenPair{}, err
}</span>
<span class="cov8" title="1">jti, err := randomToken(32)
if err != nil </span><span class="cov0" title="0">{
return TokenPair{}, err
}</span>
<span class="cov8" title="1">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 </span><span class="cov0" title="0">{
return TokenPair{}, err
}</span>
// Store refresh token jti to allow revoke
<span class="cov8" title="1">if s.tokens != nil </span><span class="cov8" title="1">{
_ = s.tokens.Set(ctx, refreshKey(jti), []byte("1"), s.cfg.JWTRefreshTTL)
}</span>
<span class="cov8" title="1">return TokenPair{AccessToken: accessToken, RefreshToken: refreshToken}, nil</span>
}
func (s *AuthService) RefreshTokens(ctx context.Context, refreshToken string) (TokenPair, error) <span class="cov8" title="1">{
if refreshToken == "" </span><span class="cov0" title="0">{
return TokenPair{}, ErrInvalidToken
}</span>
<span class="cov8" title="1">parsed, err := jwt.Parse(refreshToken, func(t *jwt.Token) (any, error) </span><span class="cov8" title="1">{
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok </span><span class="cov0" title="0">{
return nil, ErrInvalidToken
}</span>
<span class="cov8" title="1">return []byte(s.cfg.JWTRefreshSecret), nil</span>
})
<span class="cov8" title="1">if err != nil || !parsed.Valid </span><span class="cov0" title="0">{
return TokenPair{}, ErrInvalidToken
}</span>
<span class="cov8" title="1">claims, ok := parsed.Claims.(jwt.MapClaims)
if !ok </span><span class="cov0" title="0">{
return TokenPair{}, ErrInvalidToken
}</span>
<span class="cov8" title="1">if claims["type"] != "refresh" </span><span class="cov0" title="0">{
return TokenPair{}, ErrInvalidToken
}</span>
<span class="cov8" title="1">jti, _ := claims["jti"].(string)
sub, _ := claims["sub"].(string)
if jti == "" || sub == "" </span><span class="cov0" title="0">{
return TokenPair{}, ErrInvalidToken
}</span>
<span class="cov8" title="1">if s.tokens != nil </span><span class="cov8" title="1">{
v, err := s.tokens.Get(ctx, refreshKey(jti))
if err != nil || v == nil </span><span class="cov8" title="1">{
return TokenPair{}, ErrInvalidToken
}</span>
<span class="cov8" title="1">_ = s.tokens.Delete(ctx, refreshKey(jti))</span>
}
<span class="cov8" title="1">userID, err := uuidv7.ParseString(sub)
if err != nil </span><span class="cov0" title="0">{
return TokenPair{}, ErrInvalidToken
}</span>
<span class="cov8" title="1">user, err := s.repo.GetUserByID(ctx, userID)
if err != nil || user == nil </span><span class="cov0" title="0">{
return TokenPair{}, ErrInvalidToken
}</span>
<span class="cov8" title="1">return s.IssueTokens(ctx, user)</span>
}
func (s *AuthService) RevokeRefreshToken(ctx context.Context, refreshToken string) error <span class="cov0" title="0">{
if refreshToken == "" </span><span class="cov0" title="0">{
return nil
}</span>
<span class="cov0" title="0">parsed, err := jwt.Parse(refreshToken, func(t *jwt.Token) (any, error) </span><span class="cov0" title="0">{
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok </span><span class="cov0" title="0">{
return nil, ErrInvalidToken
}</span>
<span class="cov0" title="0">return []byte(s.cfg.JWTRefreshSecret), nil</span>
})
<span class="cov0" title="0">if err != nil || !parsed.Valid </span><span class="cov0" title="0">{
return nil
}</span>
<span class="cov0" title="0">claims, ok := parsed.Claims.(jwt.MapClaims)
if !ok </span><span class="cov0" title="0">{
return nil
}</span>
<span class="cov0" title="0">jti, _ := claims["jti"].(string)
if jti != "" &amp;&amp; s.tokens != nil </span><span class="cov0" title="0">{
_ = s.tokens.Delete(ctx, refreshKey(jti))
}</span>
<span class="cov0" title="0">return nil</span>
}
func (s *AuthService) ParseAccessToken(ctx context.Context, accessToken string) ([]byte, error) <span class="cov0" title="0">{
if accessToken == "" </span><span class="cov0" title="0">{
return nil, ErrInvalidToken
}</span>
<span class="cov0" title="0">parsed, err := jwt.Parse(accessToken, func(t *jwt.Token) (any, error) </span><span class="cov0" title="0">{
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok </span><span class="cov0" title="0">{
return nil, ErrInvalidToken
}</span>
<span class="cov0" title="0">return []byte(s.cfg.JWTAccessSecret), nil</span>
})
<span class="cov0" title="0">if err != nil || !parsed.Valid </span><span class="cov0" title="0">{
return nil, ErrInvalidToken
}</span>
<span class="cov0" title="0">claims, ok := parsed.Claims.(jwt.MapClaims)
if !ok || claims["type"] != "access" </span><span class="cov0" title="0">{
return nil, ErrInvalidToken
}</span>
<span class="cov0" title="0">sub, _ := claims["sub"].(string)
if sub == "" </span><span class="cov0" title="0">{
return nil, ErrInvalidToken
}</span>
<span class="cov0" title="0">return uuidv7.ParseString(sub)</span>
}
func (s *AuthService) GetUserByID(ctx context.Context, id []byte) (*auth.User, error) <span class="cov0" title="0">{
return s.repo.GetUserByID(ctx, id)
}</span>
func (s *AuthService) GetRoleByID(ctx context.Context, id []byte) (*auth.Role, error) <span class="cov0" title="0">{
if s.roleRepo == nil </span><span class="cov0" title="0">{
return nil, errors.New("role repo not configured")
}</span>
<span class="cov0" title="0">return s.roleRepo.GetRoleByID(ctx, id)</span>
}
func (s *AuthService) AccessTTL() time.Duration <span class="cov0" title="0">{ return s.cfg.JWTAccessTTL }</span>
func (s *AuthService) RefreshTTL() time.Duration <span class="cov0" title="0">{ return s.cfg.JWTRefreshTTL }</span>
func (s *AuthService) CookieDomain() string <span class="cov0" title="0">{ return s.cfg.JWTCookieDomain }</span>
func (s *AuthService) CookieSecure() bool <span class="cov0" title="0">{ return s.cfg.JWTCookieSecure }</span>
func (s *AuthService) CookieSameSite() string <span class="cov0" title="0">{ return s.cfg.JWTCookieSameSite }</span>
func (s *AuthService) AccessCookieName() string <span class="cov0" title="0">{ return s.cfg.JWTAccessCookieName }</span>
func (s *AuthService) RefreshCookieName() string <span class="cov0" title="0">{ return s.cfg.JWTRefreshCookieName }</span>
func (s *AuthService) SSOSuccessRedirectURL() string <span class="cov0" title="0">{ return s.cfg.SSOSuccessRedirectURL }</span>
// VerifyEmail verifies a token and marks the user as verified.
func (s *AuthService) VerifyEmail(ctx context.Context, token string) error <span class="cov0" title="0">{
if token == "" </span><span class="cov0" title="0">{
return ErrInvalidToken
}</span>
<span class="cov0" title="0">key := emailVerifyKey(token)
data, err := s.tokens.Get(ctx, key)
if err != nil || len(data) != 16 </span><span class="cov0" title="0">{
return ErrInvalidToken
}</span>
<span class="cov0" title="0">_ = s.tokens.Delete(ctx, key)
return s.repo.MarkEmailVerified(ctx, data)</span>
}
// SSO Microsoft (Entra) login flow: exchange code -&gt; upsert identity -&gt; get user.
func (s *AuthService) LoginWithMicrosoft(ctx context.Context, code string) (*auth.UserIdentity, error) <span class="cov0" title="0">{
claims, err := s.sso.ExchangeCode(ctx, code)
if err != nil </span><span class="cov0" title="0">{
return nil, err
}</span>
// If identity already exists, return it.
<span class="cov0" title="0">existing, err := s.repo.GetIdentityByProviderSubject(ctx, "microsoft", claims.Subject)
if err != nil </span><span class="cov0" title="0">{
return nil, err
}</span>
<span class="cov0" title="0">if existing != nil &amp;&amp; len(existing.UserID) == 16 </span><span class="cov0" title="0">{
return existing, nil
}</span>
// Ensure user exists for this identity
<span class="cov0" title="0">roleID, err := s.GetRoleIDByName(ctx, "user")
if err != nil </span><span class="cov0" title="0">{
return nil, err
}</span>
<span class="cov0" title="0">firstName, lastName := splitName(claims.Name)
user := &amp;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 </span><span class="cov0" title="0">{
return nil, err
}</span>
<span class="cov0" title="0">identity := &amp;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 </span><span class="cov0" title="0">{
return nil, err
}</span>
<span class="cov0" title="0">return identity, nil</span>
}
// TOTP verify: decrypt secret and verify code (6 digits).
func (s *AuthService) VerifyTOTP(ctx context.Context, userID []byte, code string) (bool, error) <span class="cov0" title="0">{
record, err := s.repo.GetUserTOTP(ctx, userID)
if err != nil || record == nil || !record.Enabled </span><span class="cov0" title="0">{
return false, nil
}</span>
<span class="cov0" title="0">secret, err := s.protector.Decrypt(record.SecretEncrypted)
if err != nil </span><span class="cov0" title="0">{
return false, err
}</span>
<span class="cov0" title="0">ok := verifyTOTP(string(secret), code)
if ok </span><span class="cov0" title="0">{
_ = s.repo.UpdateUserTOTPLastUsed(ctx, userID)
}</span>
<span class="cov0" title="0">return ok, nil</span>
}
func (s *AuthService) CreateTOTPChallenge(ctx context.Context, userID []byte) (string, error) <span class="cov0" title="0">{
if s.tokens == nil </span><span class="cov0" title="0">{
return "", errors.New("token store not configured")
}</span>
<span class="cov0" title="0">token, err := randomToken(32)
if err != nil </span><span class="cov0" title="0">{
return "", err
}</span>
<span class="cov0" title="0">if err := s.tokens.Set(ctx, totpChallengeKey(token), userID, s.cfg.TOTPChallengeTTL); err != nil </span><span class="cov0" title="0">{
return "", err
}</span>
<span class="cov0" title="0">return token, nil</span>
}
func (s *AuthService) ConsumeTOTPChallenge(ctx context.Context, token string) ([]byte, error) <span class="cov0" title="0">{
if s.tokens == nil || token == "" </span><span class="cov0" title="0">{
return nil, ErrInvalidToken
}</span>
<span class="cov0" title="0">data, err := s.tokens.Get(ctx, totpChallengeKey(token))
if err != nil || data == nil </span><span class="cov0" title="0">{
return nil, ErrInvalidToken
}</span>
<span class="cov0" title="0">_ = s.tokens.Delete(ctx, totpChallengeKey(token))
return data, nil</span>
}
// 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) <span class="cov8" title="1">{
if s.protector == nil </span><span class="cov0" title="0">{
return "", "", "", errors.New("secret protector not configured")
}</span>
<span class="cov8" title="1">issuer = s.cfg.TOTPIssuer
key, err := totp.Generate(totp.GenerateOpts{
Issuer: issuer,
AccountName: accountName,
})
if err != nil </span><span class="cov0" title="0">{
return "", "", "", err
}</span>
<span class="cov8" title="1">secret = key.Secret()
otpAuthURL = key.URL()
enc, err := s.protector.Encrypt([]byte(secret))
if err != nil </span><span class="cov0" title="0">{
return "", "", "", err
}</span>
<span class="cov8" title="1">record := &amp;auth.UserTOTP{
UserID: userID,
SecretEncrypted: enc,
Enabled: false,
}
if err := s.repo.UpsertUserTOTP(ctx, record); err != nil </span><span class="cov0" title="0">{
return "", "", "", err
}</span>
<span class="cov8" title="1">return secret, otpAuthURL, issuer, nil</span>
}
func (s *AuthService) DisableTOTP(ctx context.Context, userID []byte) error <span class="cov0" title="0">{
return s.repo.DisableUserTOTP(ctx, userID)
}</span>
func (s *AuthService) ConfirmTOTP(ctx context.Context, userID []byte, code string) (bool, error) <span class="cov8" title="1">{
record, err := s.repo.GetUserTOTP(ctx, userID)
if err != nil || record == nil </span><span class="cov0" title="0">{
return false, ErrInvalidToken
}</span>
<span class="cov8" title="1">secret, err := s.protector.Decrypt(record.SecretEncrypted)
if err != nil </span><span class="cov0" title="0">{
return false, err
}</span>
<span class="cov8" title="1">ok := verifyTOTP(string(secret), code)
if !ok </span><span class="cov0" title="0">{
return false, nil
}</span>
<span class="cov8" title="1">record.Enabled = true
if err := s.repo.UpsertUserTOTP(ctx, record); err != nil </span><span class="cov0" title="0">{
return false, err
}</span>
<span class="cov8" title="1">return true, nil</span>
}
func (s *AuthService) sendEmailVerification(ctx context.Context, user *auth.User) error <span class="cov8" title="1">{
if s.tokens == nil || s.cfg.EmailVerifyURL == "" </span><span class="cov0" title="0">{
return nil
}</span>
<span class="cov8" title="1">token, err := randomToken(32)
if err != nil </span><span class="cov0" title="0">{
return err
}</span>
<span class="cov8" title="1">if err := s.tokens.Set(ctx, emailVerifyKey(token), user.ID, s.cfg.EmailVerifyTTL); err != nil </span><span class="cov0" title="0">{
return err
}</span>
<span class="cov8" title="1">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 </span><span class="cov0" title="0">{
return s.emailQueue.Enqueue(ctx, EmailJob{
To: user.Email,
Subject: subject,
Body: body,
})
}</span>
<span class="cov8" title="1">if s.mailer != nil </span><span class="cov0" title="0">{
return s.mailer.Send(ctx, user.Email, subject, body)
}</span>
<span class="cov8" title="1">return nil</span>
}
func (s *AuthService) hashPassword(password string) (hash []byte, salt []byte, err error) <span class="cov8" title="1">{
salt = make([]byte, 16)
if _, err = rand.Read(salt); err != nil </span><span class="cov0" title="0">{
return nil, nil, err
}</span>
<span class="cov8" title="1">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</span>
}
func (s *AuthService) verifyPassword(password string, hash, salt []byte) bool <span class="cov8" title="1">{
if len(hash) == 0 || len(salt) == 0 </span><span class="cov0" title="0">{
return false
}</span>
<span class="cov8" title="1">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)</span>
}
func randomToken(n int) (string, error) <span class="cov8" title="1">{
b := make([]byte, n)
if _, err := rand.Read(b); err != nil </span><span class="cov0" title="0">{
return "", err
}</span>
<span class="cov8" title="1">return base64.RawURLEncoding.EncodeToString(b), nil</span>
}
func emailVerifyKey(token string) string <span class="cov8" title="1">{
return "auth:email_verify:" + token
}</span>
func ssoStateKey(state string) string <span class="cov0" title="0">{
return "auth:sso_state:" + state
}</span>
func refreshKey(jti string) string <span class="cov8" title="1">{
return "auth:refresh:" + jti
}</span>
func totpChallengeKey(token string) string <span class="cov0" title="0">{
return "auth:totp_challenge:" + token
}</span>
func bytesToString(b []byte) string <span class="cov8" title="1">{
s, _ := uuidv7.BytesToString(b)
return s
}</span>
func splitName(full string) (string, string) <span class="cov0" title="0">{
full = strings.TrimSpace(full)
if full == "" </span><span class="cov0" title="0">{
return "User", ""
}</span>
<span class="cov0" title="0">parts := strings.Fields(full)
if len(parts) == 1 </span><span class="cov0" title="0">{
return parts[0], ""
}</span>
<span class="cov0" title="0">return parts[0], strings.Join(parts[1:], " ")</span>
}
func ptrTime(t time.Time) *time.Time <span class="cov0" title="0">{ return &amp;t }</span>
func subtleConstantTimeCompare(a, b []byte) bool <span class="cov8" title="1">{
if len(a) != len(b) </span><span class="cov0" title="0">{
return false
}</span>
<span class="cov8" title="1">var v byte
for i := 0; i &lt; len(a); i++ </span><span class="cov8" title="1">{
v |= a[i] ^ b[i]
}</span>
<span class="cov8" title="1">return v == 0</span>
}
func verifyTOTP(secret, code string) bool <span class="cov8" title="1">{
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
}</span>
</pre>
<pre class="file" id="file29" style="display: none">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) <span class="cov0" title="0">{
return json.Marshal(job)
}</span>
func DecodeEmailJob(b []byte) (*EmailJob, error) <span class="cov0" title="0">{
var job EmailJob
if err := json.Unmarshal(b, &amp;job); err != nil </span><span class="cov0" title="0">{
return nil, err
}</span>
<span class="cov0" title="0">return &amp;job, nil</span>
}
</pre>
<pre class="file" id="file30" style="display: none">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 <span class="cov0" title="0">{
return &amp;MicrosoftSSOClient{
cfg: cfg,
client: &amp;http.Client{
Timeout: 10 * time.Second,
},
}
}</span>
func (m *MicrosoftSSOClient) AuthCodeURL(state string) string <span class="cov0" title="0">{
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())
}</span>
func (m *MicrosoftSSOClient) ExchangeCode(ctx context.Context, code string) (MicrosoftClaims, error) <span class="cov0" title="0">{
if code == "" </span><span class="cov0" title="0">{
return MicrosoftClaims{}, errors.New("missing code")
}</span>
<span class="cov0" title="0">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 </span><span class="cov0" title="0">{
return MicrosoftClaims{}, err
}</span>
<span class="cov0" title="0">req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := m.client.Do(req)
if err != nil </span><span class="cov0" title="0">{
return MicrosoftClaims{}, err
}</span>
<span class="cov0" title="0">defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode &lt; 200 || resp.StatusCode &gt;= 300 </span><span class="cov0" title="0">{
return MicrosoftClaims{}, fmt.Errorf("token exchange failed: %s", string(body))
}</span>
<span class="cov0" title="0">var token struct {
IDToken string `json:"id_token"`
}
if err := json.Unmarshal(body, &amp;token); err != nil </span><span class="cov0" title="0">{
return MicrosoftClaims{}, err
}</span>
<span class="cov0" title="0">if token.IDToken == "" </span><span class="cov0" title="0">{
return MicrosoftClaims{}, errors.New("missing id_token")
}</span>
<span class="cov0" title="0">claims, err := parseJWTClaims(token.IDToken)
if err != nil </span><span class="cov0" title="0">{
return MicrosoftClaims{}, err
}</span>
<span class="cov0" title="0">sub, _ := claims["sub"].(string)
email, _ := claims["email"].(string)
name, _ := claims["name"].(string)
if email == "" </span><span class="cov0" title="0">{
// Some tenants use preferred_username
email, _ = claims["preferred_username"].(string)
}</span>
<span class="cov0" title="0">return MicrosoftClaims{
Subject: sub,
Email: email,
Name: name,
}, nil</span>
}
func parseJWTClaims(token string) (map[string]any, error) <span class="cov0" title="0">{
parts := strings.Split(token, ".")
if len(parts) &lt; 2 </span><span class="cov0" title="0">{
return nil, errors.New("invalid jwt")
}</span>
<span class="cov0" title="0">payload, err := decodeSegment(parts[1])
if err != nil </span><span class="cov0" title="0">{
return nil, err
}</span>
<span class="cov0" title="0">var claims map[string]any
if err := json.Unmarshal(payload, &amp;claims); err != nil </span><span class="cov0" title="0">{
return nil, err
}</span>
<span class="cov0" title="0">return claims, nil</span>
}
func decodeSegment(seg string) ([]byte, error) <span class="cov0" title="0">{
seg = strings.ReplaceAll(seg, "-", "+")
seg = strings.ReplaceAll(seg, "_", "/")
switch len(seg) % 4 </span>{
case 2:<span class="cov0" title="0">
seg += "=="</span>
case 3:<span class="cov0" title="0">
seg += "="</span>
}
<span class="cov0" title="0">return io.ReadAll(base64.NewDecoder(base64.StdEncoding, strings.NewReader(seg)))</span>
}
</pre>
<pre class="file" id="file31" style="display: none">package service
import (
"context"
"errors"
"strings"
"wucher/internal/domain/auth"
)
type RoleService struct {
repo auth.RoleRepository
}
func NewRoleService(repo auth.RoleRepository) *RoleService <span class="cov0" title="0">{
return &amp;RoleService{repo: repo}
}</span>
func (s *RoleService) Create(ctx context.Context, role *auth.Role) error <span class="cov0" title="0">{
if strings.TrimSpace(role.Name) == "" </span><span class="cov0" title="0">{
return errors.New("name is required")
}</span>
<span class="cov0" title="0">return s.repo.CreateRole(ctx, role)</span>
}
func (s *RoleService) Update(ctx context.Context, role *auth.Role) error <span class="cov0" title="0">{
if strings.TrimSpace(role.Name) == "" </span><span class="cov0" title="0">{
return errors.New("name is required")
}</span>
<span class="cov0" title="0">return s.repo.UpdateRole(ctx, role)</span>
}
func (s *RoleService) Delete(ctx context.Context, id []byte) error <span class="cov0" title="0">{
return s.repo.DeleteRole(ctx, id)
}</span>
func (s *RoleService) GetByID(ctx context.Context, id []byte) (*auth.Role, error) <span class="cov0" title="0">{
return s.repo.GetRoleByID(ctx, id)
}</span>
func (s *RoleService) GetByName(ctx context.Context, name string) (*auth.Role, error) <span class="cov0" title="0">{
return s.repo.GetRoleByName(ctx, name)
}</span>
func (s *RoleService) List(ctx context.Context, filterName, sort string, limit, offset int) ([]auth.Role, int64, error) <span class="cov0" title="0">{
return s.repo.ListRoles(ctx, filterName, normalizeSort(sort), limit, offset)
}</span>
func normalizeSort(sort string) string <span class="cov0" title="0">{
// Allow list: name, -name, created_at, -created_at, updated_at, -updated_at
switch sort </span>{
case "name":<span class="cov0" title="0">
return "name ASC"</span>
case "-name":<span class="cov0" title="0">
return "name DESC"</span>
case "created_at":<span class="cov0" title="0">
return "created_at ASC"</span>
case "-created_at":<span class="cov0" title="0">
return "created_at DESC"</span>
case "updated_at":<span class="cov0" title="0">
return "updated_at ASC"</span>
case "-updated_at":<span class="cov0" title="0">
return "updated_at DESC"</span>
default:<span class="cov0" title="0">
return ""</span>
}
}
</pre>
<pre class="file" id="file32" style="display: none">package service
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"errors"
"io"
)
type AESGCMProtector struct {
key []byte
}
func NewAESGCMProtectorFromBase64(keyB64 string) (*AESGCMProtector, error) <span class="cov8" title="1">{
if keyB64 == "" </span><span class="cov0" title="0">{
return nil, errors.New("empty key")
}</span>
<span class="cov8" title="1">key, err := base64.StdEncoding.DecodeString(keyB64)
if err != nil </span><span class="cov0" title="0">{
return nil, err
}</span>
<span class="cov8" title="1">if len(key) != 32 </span><span class="cov0" title="0">{
return nil, errors.New("key must be 32 bytes (base64-encoded)")
}</span>
<span class="cov8" title="1">return &amp;AESGCMProtector{key: key}, nil</span>
}
func (p *AESGCMProtector) Encrypt(plain []byte) ([]byte, error) <span class="cov8" title="1">{
block, err := aes.NewCipher(p.key)
if err != nil </span><span class="cov0" title="0">{
return nil, err
}</span>
<span class="cov8" title="1">gcm, err := cipher.NewGCM(block)
if err != nil </span><span class="cov0" title="0">{
return nil, err
}</span>
<span class="cov8" title="1">nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil </span><span class="cov0" title="0">{
return nil, err
}</span>
<span class="cov8" title="1">ciphertext := gcm.Seal(nonce, nonce, plain, nil)
return ciphertext, nil</span>
}
func (p *AESGCMProtector) Decrypt(ciphertext []byte) ([]byte, error) <span class="cov8" title="1">{
block, err := aes.NewCipher(p.key)
if err != nil </span><span class="cov0" title="0">{
return nil, err
}</span>
<span class="cov8" title="1">gcm, err := cipher.NewGCM(block)
if err != nil </span><span class="cov0" title="0">{
return nil, err
}</span>
<span class="cov8" title="1">if len(ciphertext) &lt; gcm.NonceSize() </span><span class="cov0" title="0">{
return nil, errors.New("ciphertext too short")
}</span>
<span class="cov8" title="1">nonce := ciphertext[:gcm.NonceSize()]
data := ciphertext[gcm.NonceSize():]
return gcm.Open(nil, nonce, data, nil)</span>
}
</pre>
<pre class="file" id="file33" style="display: none">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 <span class="cov0" title="0">{
return &amp;SMTPSender{cfg: cfg}
}</span>
func (s *SMTPSender) Send(ctx context.Context, to, subject, body string) error <span class="cov0" title="0">{
if s.cfg.Host == "" || s.cfg.From == "" </span><span class="cov0" title="0">{
return errors.New("smtp not configured")
}</span>
<span class="cov0" title="0">if to == "" </span><span class="cov0" title="0">{
return errors.New("recipient is required")
}</span>
<span class="cov0" title="0">addr := fmt.Sprintf("%s:%d", s.cfg.Host, s.cfg.Port)
fromAddr, err := mail.ParseAddress(s.cfg.From)
if err != nil </span><span class="cov0" title="0">{
return err
}</span>
<span class="cov0" title="0">toAddr, err := mail.ParseAddress(to)
if err != nil </span><span class="cov0" title="0">{
return err
}</span>
<span class="cov0" title="0">msg := buildMessage(s.cfg.From, to, subject, body)
var c *smtp.Client
var conn net.Conn
dialer := &amp;net.Dialer{Timeout: 10 * time.Second}
tlsCfg := &amp;tls.Config{
ServerName: s.cfg.Host,
InsecureSkipVerify: s.cfg.InsecureSkipVerify,
}
if s.cfg.Port == 465 </span><span class="cov0" title="0">{
conn, err = tls.DialWithDialer(dialer, "tcp", addr, tlsCfg)
if err != nil </span><span class="cov0" title="0">{
return err
}</span>
<span class="cov0" title="0">c, err = smtp.NewClient(conn, s.cfg.Host)
if err != nil </span><span class="cov0" title="0">{
_ = conn.Close()
return err
}</span>
} else<span class="cov0" title="0"> {
conn, err = dialer.DialContext(ctx, "tcp", addr)
if err != nil </span><span class="cov0" title="0">{
return err
}</span>
<span class="cov0" title="0">c, err = smtp.NewClient(conn, s.cfg.Host)
if err != nil </span><span class="cov0" title="0">{
_ = conn.Close()
return err
}</span>
<span class="cov0" title="0">if ok, _ := c.Extension("STARTTLS"); ok </span><span class="cov0" title="0">{
if err := c.StartTLS(tlsCfg); err != nil </span><span class="cov0" title="0">{
_ = c.Close()
return err
}</span>
}
}
<span class="cov0" title="0">defer func() </span><span class="cov0" title="0">{ _ = c.Close() }</span>()
<span class="cov0" title="0">if s.cfg.Username != "" </span><span class="cov0" title="0">{
auth := smtp.PlainAuth("", s.cfg.Username, s.cfg.Password, s.cfg.Host)
if err := c.Auth(auth); err != nil </span><span class="cov0" title="0">{
return err
}</span>
}
<span class="cov0" title="0">if err := c.Mail(fromAddr.Address); err != nil </span><span class="cov0" title="0">{
return err
}</span>
<span class="cov0" title="0">if err := c.Rcpt(toAddr.Address); err != nil </span><span class="cov0" title="0">{
return err
}</span>
<span class="cov0" title="0">w, err := c.Data()
if err != nil </span><span class="cov0" title="0">{
return err
}</span>
<span class="cov0" title="0">if _, err := w.Write([]byte(msg)); err != nil </span><span class="cov0" title="0">{
_ = w.Close()
return err
}</span>
<span class="cov0" title="0">if err := w.Close(); err != nil </span><span class="cov0" title="0">{
return err
}</span>
<span class="cov0" title="0">return c.Quit()</span>
}
func buildMessage(from, to, subject, body string) string <span class="cov0" title="0">{
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")
}</span>
</pre>
<pre class="file" id="file34" style="display: none">package service
import (
"context"
"wucher/internal/domain/user"
)
type UserService struct {
repo user.Repository
}
func NewUserService(repo user.Repository) *UserService <span class="cov0" title="0">{
return &amp;UserService{repo: repo}
}</span>
func (s *UserService) Create(ctx context.Context, u *user.User) error <span class="cov0" title="0">{
return s.repo.Create(ctx, u)
}</span>
func (s *UserService) GetByID(ctx context.Context, id []byte) (*user.User, error) <span class="cov0" title="0">{
return s.repo.GetByID(ctx, id)
}</span>
func (s *UserService) GetByEmail(ctx context.Context, email string) (*user.User, error) <span class="cov0" title="0">{
return s.repo.GetByEmail(ctx, email)
}</span>
</pre>
<pre class="file" id="file35" style="display: none">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 <span class="cov8" title="1">{
return &amp;AuthHandler{
svc: svc,
validate: validators.New(),
}
}</span>
// Register godoc
// @Summary Register user with email &amp; password
// @Description Creates user (default role: admin) and sends verification link to email.
// @Description Next: open verify link -&gt; 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 <span class="cov0" title="0">{
if h.svc.RegisterDisabled() </span><span class="cov0" title="0">{
return response.WriteErrors(c, fiber.StatusForbidden, []jsonapi.ErrorObject{{
Status: "403",
Title: "Registration disabled",
Detail: "registration is disabled in this environment",
}})
}</span>
<span class="cov0" title="0">var req dto.RegisterRequest
if err := c.BodyParser(&amp;req); err != nil </span><span class="cov0" title="0">{
return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "Invalid JSON",
Detail: err.Error(),
}})
}</span>
<span class="cov0" title="0">if errs := h.validate.ValidateStruct(req); errs != nil </span><span class="cov0" title="0">{
return response.WriteErrors(c, fiber.StatusUnprocessableEntity, errs)
}</span>
<span class="cov0" title="0">roleID, err := h.svc.GetRoleIDByName(c.Context(), h.svc.DefaultRoleName())
if err != nil </span><span class="cov0" title="0">{
return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "Role not found",
Detail: "default role not found",
}})
}</span>
<span class="cov0" title="0">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 </span><span class="cov0" title="0">{
return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "Registration failed",
Detail: err.Error(),
}})
}</span>
<span class="cov0" title="0">tokens, err := h.svc.IssueTokens(c.Context(), user)
if err != nil </span><span class="cov0" title="0">{
return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "Token issue failed",
Detail: err.Error(),
}})
}</span>
<span class="cov0" title="0">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 </span><span class="cov0" title="0">{ s, _ := uuidv7.BytesToString(roleID); return s }</span>(),
"mobile_phone": user.MobilePhone,
},
}
<span class="cov0" title="0">return response.Write(c, fiber.StatusCreated, resp)</span>
}
// Login godoc
// @Summary Login with email &amp; 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 <span class="cov8" title="1">{
var req dto.LoginRequest
if err := c.BodyParser(&amp;req); err != nil </span><span class="cov0" title="0">{
return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "Invalid JSON",
Detail: err.Error(),
}})
}</span>
<span class="cov8" title="1">if errs := h.validate.ValidateStruct(req); errs != nil </span><span class="cov0" title="0">{
return response.WriteErrors(c, fiber.StatusUnprocessableEntity, errs)
}</span>
<span class="cov8" title="1">user, err := h.svc.LoginWithEmailPassword(c.Context(), req.Data.Attributes.Email, req.Data.Attributes.Password)
if err != nil </span><span class="cov0" title="0">{
return response.WriteErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
Status: "401",
Title: "Unauthorized",
Detail: err.Error(),
}})
}</span>
<span class="cov8" title="1">if ok, _ := h.svc.IsTOTPEnabled(c.Context(), user.ID); ok </span><span class="cov8" title="1">{
token, err := h.svc.CreateTOTPChallenge(c.Context(), user.ID)
if err != nil </span><span class="cov0" title="0">{
return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "TOTP challenge failed",
Detail: err.Error(),
}})
}</span>
<span class="cov8" title="1">return response.Write(c, fiber.StatusAccepted, jsonapi.Resource{
Type: "auth_totp_required",
Attributes: map[string]any{
"requires_totp": true,
"challenge_token": token,
},
})</span>
}
<span class="cov0" title="0">tokens, err := h.svc.IssueTokens(c.Context(), user)
if err != nil </span><span class="cov0" title="0">{
return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "Token issue failed",
Detail: err.Error(),
}})
}</span>
<span class="cov0" title="0">setAuthCookies(c, h.svc, tokens)
id, _ := uuidv7.BytesToString(user.ID)
roleID, _ := uuidv7.BytesToString(user.RoleID)
var emailVerifiedAt string
if user.EmailVerifiedAt != nil </span><span class="cov0" title="0">{
emailVerifiedAt = user.EmailVerifiedAt.UTC().Format("2006-01-02T15:04:05Z07:00")
}</span>
<span class="cov0" title="0">var roleAttrs any
role, _ := h.svc.GetRoleByID(c.Context(), user.RoleID)
if role != nil </span><span class="cov0" title="0">{
roleIDStr, _ := uuidv7.BytesToString(role.ID)
roleAttrs = map[string]any{
"id": roleIDStr,
"name": role.Name,
"description": role.Description,
}
}</span>
<span class="cov0" title="0">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)</span>
}
// 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 <span class="cov0" title="0">{
token := c.Query("token")
if token == "" </span><span class="cov0" title="0">{
return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "Invalid token",
Detail: "token is required",
Source: &amp;jsonapi.ErrorSource{Pointer: "/query/token"},
}})
}</span>
<span class="cov0" title="0">if err := h.svc.VerifyEmail(c.Context(), token); err != nil </span><span class="cov0" title="0">{
return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "Invalid token",
Detail: err.Error(),
}})
}</span>
<span class="cov0" title="0">return response.Write(c, fiber.StatusOK, jsonapi.Resource{
Type: "auth_verify_email",
Attributes: map[string]any{
"verified": true,
},
})</span>
}
// 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 <span class="cov0" title="0">{
urlStr, err := h.svc.BeginMicrosoftLogin(c.Context())
if err != nil </span><span class="cov0" title="0">{
return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "SSO not configured",
Detail: err.Error(),
}})
}</span>
<span class="cov0" title="0">return response.Write(c, fiber.StatusOK, jsonapi.Resource{
Type: "auth_microsoft_url",
Attributes: map[string]any{
"url": urlStr,
},
})</span>
}
// 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 <span class="cov0" title="0">{
code := c.Query("code")
state := c.Query("state")
if code == "" || state == "" </span><span class="cov0" title="0">{
return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "Invalid callback",
Detail: "code and state are required",
}})
}</span>
<span class="cov0" title="0">identity, err := h.svc.CompleteMicrosoftLogin(c.Context(), code, state)
if err != nil </span><span class="cov0" title="0">{
return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "SSO login failed",
Detail: err.Error(),
}})
}</span>
// Issue cookies and redirect to FE if configured
<span class="cov0" title="0">user, err := h.svc.GetUserByID(c.Context(), identity.UserID)
if err == nil &amp;&amp; user != nil </span><span class="cov0" title="0">{
tokens, err := h.svc.IssueTokens(c.Context(), user)
if err == nil </span><span class="cov0" title="0">{
setAuthCookies(c, h.svc, tokens)
}</span>
}
<span class="cov0" title="0">if redirect := h.svc.SSOSuccessRedirectURL(); redirect != "" </span><span class="cov0" title="0">{
return c.Redirect(redirect, fiber.StatusFound)
}</span>
<span class="cov0" title="0">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,
},
})</span>
}
// 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 <span class="cov0" title="0">{
userID, ok := c.Locals("user_id").([]byte)
if !ok || len(userID) == 0 </span><span class="cov0" title="0">{
return response.WriteErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
Status: "401",
Title: "Unauthorized",
Detail: "missing auth context",
}})
}</span>
<span class="cov0" title="0">user, err := h.svc.GetUserByID(c.Context(), userID)
if err != nil || user == nil </span><span class="cov0" title="0">{
return response.WriteErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
Status: "401",
Title: "Unauthorized",
Detail: "user not found",
}})
}</span>
<span class="cov0" title="0">id, _ := uuidv7.BytesToString(user.ID)
roleID, _ := uuidv7.BytesToString(user.RoleID)
var emailVerifiedAt string
if user.EmailVerifiedAt != nil </span><span class="cov0" title="0">{
emailVerifiedAt = user.EmailVerifiedAt.UTC().Format("2006-01-02T15:04:05Z07:00")
}</span>
<span class="cov0" title="0">var roleAttrs any
role, _ := h.svc.GetRoleByID(c.Context(), user.RoleID)
if role != nil </span><span class="cov0" title="0">{
roleIDStr, _ := uuidv7.BytesToString(role.ID)
roleAttrs = map[string]any{
"id": roleIDStr,
"name": role.Name,
"description": role.Description,
}
}</span>
<span class="cov0" title="0">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,
},
})</span>
}
// 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 <span class="cov0" title="0">{
refreshToken := c.Cookies(h.svc.RefreshCookieName())
tokens, err := h.svc.RefreshTokens(c.Context(), refreshToken)
if err != nil </span><span class="cov0" title="0">{
return response.WriteErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
Status: "401",
Title: "Unauthorized",
Detail: err.Error(),
}})
}</span>
<span class="cov0" title="0">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()),
},
})</span>
}
// 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 <span class="cov0" title="0">{
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,
},
})
}</span>
// 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 <span class="cov0" title="0">{
var req dto.TOTPSetupRequest
if err := c.BodyParser(&amp;req); err != nil </span><span class="cov0" title="0">{
return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "Invalid JSON",
Detail: err.Error(),
}})
}</span>
<span class="cov0" title="0">if errs := h.validate.ValidateStruct(req); errs != nil </span><span class="cov0" title="0">{
return response.WriteErrors(c, fiber.StatusUnprocessableEntity, errs)
}</span>
<span class="cov0" title="0">userID, err := uuidv7.ParseString(req.Data.Attributes.UserID)
if err != nil </span><span class="cov0" title="0">{
return response.WriteErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
Status: "422",
Title: "Validation error",
Detail: "user_id is invalid UUID",
Source: &amp;jsonapi.ErrorSource{Pointer: "/data/attributes/user_id"},
}})
}</span>
<span class="cov0" title="0">secret, otpURL, issuer, err := h.svc.SetupTOTP(c.Context(), userID, req.Data.Attributes.Email)
if err != nil </span><span class="cov0" title="0">{
return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "TOTP setup failed",
Detail: err.Error(),
}})
}</span>
<span class="cov0" title="0">return response.Write(c, fiber.StatusOK, jsonapi.Resource{
Type: "auth_totp_setup",
Attributes: map[string]any{
"secret": secret,
"otpauth_url": otpURL,
"issuer": issuer,
},
})</span>
}
// 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 <span class="cov0" title="0">{
var req dto.TOTPConfirmRequest
if err := c.BodyParser(&amp;req); err != nil </span><span class="cov0" title="0">{
return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "Invalid JSON",
Detail: err.Error(),
}})
}</span>
<span class="cov0" title="0">if errs := h.validate.ValidateStruct(req); errs != nil </span><span class="cov0" title="0">{
return response.WriteErrors(c, fiber.StatusUnprocessableEntity, errs)
}</span>
<span class="cov0" title="0">userID, err := uuidv7.ParseString(req.Data.Attributes.UserID)
if err != nil </span><span class="cov0" title="0">{
return response.WriteErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
Status: "422",
Title: "Validation error",
Detail: "user_id is invalid UUID",
Source: &amp;jsonapi.ErrorSource{Pointer: "/data/attributes/user_id"},
}})
}</span>
<span class="cov0" title="0">ok, err := h.svc.ConfirmTOTP(c.Context(), userID, strings.TrimSpace(req.Data.Attributes.Code))
if err != nil || !ok </span><span class="cov0" title="0">{
return response.WriteErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
Status: "401",
Title: "Invalid TOTP",
Detail: "code is invalid or expired",
}})
}</span>
<span class="cov0" title="0">return response.Write(c, fiber.StatusOK, jsonapi.Resource{
Type: "auth_totp_confirm",
Attributes: map[string]any{
"confirmed": true,
},
})</span>
}
// 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 <span class="cov0" title="0">{
var req dto.TOTPVerifyRequest
if err := c.BodyParser(&amp;req); err != nil </span><span class="cov0" title="0">{
return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "Invalid JSON",
Detail: err.Error(),
}})
}</span>
<span class="cov0" title="0">if errs := h.validate.ValidateStruct(req); errs != nil </span><span class="cov0" title="0">{
return response.WriteErrors(c, fiber.StatusUnprocessableEntity, errs)
}</span>
<span class="cov0" title="0">userID, err := h.svc.ConsumeTOTPChallenge(c.Context(), strings.TrimSpace(req.Data.Attributes.ChallengeToken))
if err != nil </span><span class="cov0" title="0">{
return response.WriteErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
Status: "401",
Title: "Invalid challenge",
Detail: "challenge token is invalid or expired",
}})
}</span>
<span class="cov0" title="0">ok, err := h.svc.VerifyTOTP(c.Context(), userID, strings.TrimSpace(req.Data.Attributes.Code))
if err != nil || !ok </span><span class="cov0" title="0">{
return response.WriteErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
Status: "401",
Title: "Invalid TOTP",
Detail: "code is invalid or expired",
}})
}</span>
<span class="cov0" title="0">user, err := h.svc.GetUserByID(c.Context(), userID)
if err != nil || user == nil </span><span class="cov0" title="0">{
return response.WriteErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
Status: "401",
Title: "Unauthorized",
Detail: "user not found",
}})
}</span>
<span class="cov0" title="0">tokens, err := h.svc.IssueTokens(c.Context(), user)
if err != nil </span><span class="cov0" title="0">{
return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "Token issue failed",
Detail: err.Error(),
}})
}</span>
<span class="cov0" title="0">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()),
},
})</span>
}
// 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 <span class="cov0" title="0">{
var req dto.TOTPDisableRequest
if err := c.BodyParser(&amp;req); err != nil </span><span class="cov0" title="0">{
return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "Invalid JSON",
Detail: err.Error(),
}})
}</span>
<span class="cov0" title="0">if errs := h.validate.ValidateStruct(req); errs != nil </span><span class="cov0" title="0">{
return response.WriteErrors(c, fiber.StatusUnprocessableEntity, errs)
}</span>
<span class="cov0" title="0">userID, err := uuidv7.ParseString(req.Data.Attributes.UserID)
if err != nil </span><span class="cov0" title="0">{
return response.WriteErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
Status: "422",
Title: "Validation error",
Detail: "user_id is invalid UUID",
Source: &amp;jsonapi.ErrorSource{Pointer: "/data/attributes/user_id"},
}})
}</span>
<span class="cov0" title="0">if err := h.svc.DisableTOTP(c.Context(), userID); err != nil </span><span class="cov0" title="0">{
return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "Disable TOTP failed",
Detail: err.Error(),
}})
}</span>
<span class="cov0" title="0">return response.Write(c, fiber.StatusOK, jsonapi.Resource{
Type: "auth_totp_disable",
Attributes: map[string]any{
"disabled": true,
},
})</span>
}
func setAuthCookies(c *fiber.Ctx, svc *service.AuthService, tokens service.TokenPair) <span class="cov0" title="0">{
c.Cookie(&amp;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(&amp;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()),
})
}</span>
func clearAuthCookies(c *fiber.Ctx, svc *service.AuthService) <span class="cov0" title="0">{
c.Cookie(&amp;fiber.Cookie{
Name: svc.AccessCookieName(),
Value: "",
HTTPOnly: true,
Secure: svc.CookieSecure(),
SameSite: parseSameSite(svc.CookieSameSite()),
Domain: svc.CookieDomain(),
Path: "/",
MaxAge: -1,
})
c.Cookie(&amp;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,
})
}</span>
func parseSameSite(v string) string <span class="cov0" title="0">{
switch strings.ToLower(v) </span>{
case "strict":<span class="cov0" title="0">
return "Strict"</span>
case "none":<span class="cov0" title="0">
return "None"</span>
default:<span class="cov0" title="0">
return "Lax"</span>
}
}
</pre>
<pre class="file" id="file36" style="display: none">package handlers
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/transport/http/response"
"wucher/internal/transport/http/jsonapi"
)
type HealthHandler struct{}
func NewHealthHandler() *HealthHandler <span class="cov0" title="0">{
return &amp;HealthHandler{}
}</span>
func (h *HealthHandler) Handle(c *fiber.Ctx) error <span class="cov0" title="0">{
return response.Write(c, fiber.StatusOK, jsonapi.Resource{
Type: "health",
Attributes: map[string]any{
"status": "ok",
},
})
}</span>
</pre>
<pre class="file" id="file37" style="display: none">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 <span class="cov0" title="0">{
return &amp;RoleHandler{
svc: svc,
validate: validators.New(),
}
}</span>
// 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 <span class="cov0" title="0">{
var req dto.RoleCreateRequest
if err := c.BodyParser(&amp;req); err != nil </span><span class="cov0" title="0">{
return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "Invalid JSON",
Detail: err.Error(),
}})
}</span>
<span class="cov0" title="0">if errs := h.validate.ValidateStruct(req); errs != nil </span><span class="cov0" title="0">{
return response.WriteErrors(c, fiber.StatusUnprocessableEntity, errs)
}</span>
<span class="cov0" title="0">role := &amp;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 </span><span class="cov0" title="0">{
return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "Create failed",
Detail: err.Error(),
}})
}</span>
<span class="cov0" title="0">return response.Write(c, fiber.StatusCreated, roleResource(role))</span>
}
// 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 <span class="cov0" title="0">{
idStr := c.Params("id")
roleID, err := uuidv7.ParseString(idStr)
if err != nil </span><span class="cov0" title="0">{
return response.WriteErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
Status: "422",
Title: "Validation error",
Detail: "id is invalid UUID",
Source: &amp;jsonapi.ErrorSource{Pointer: "/path/id"},
}})
}</span>
<span class="cov0" title="0">var req dto.RoleUpdateRequest
if err := c.BodyParser(&amp;req); err != nil </span><span class="cov0" title="0">{
return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "Invalid JSON",
Detail: err.Error(),
}})
}</span>
<span class="cov0" title="0">if errs := h.validate.ValidateStruct(req); errs != nil </span><span class="cov0" title="0">{
return response.WriteErrors(c, fiber.StatusUnprocessableEntity, errs)
}</span>
<span class="cov0" title="0">existing, err := h.svc.GetByID(c.Context(), roleID)
if err != nil || existing == nil </span><span class="cov0" title="0">{
return response.WriteErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{
Status: "404",
Title: "Not found",
Detail: "role not found",
}})
}</span>
<span class="cov0" title="0">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 </span><span class="cov0" title="0">{
return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "Update failed",
Detail: err.Error(),
}})
}</span>
<span class="cov0" title="0">return response.Write(c, fiber.StatusOK, roleResource(existing))</span>
}
// 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 <span class="cov0" title="0">{
idStr := c.Params("id")
roleID, err := uuidv7.ParseString(idStr)
if err != nil </span><span class="cov0" title="0">{
return response.WriteErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
Status: "422",
Title: "Validation error",
Detail: "id is invalid UUID",
Source: &amp;jsonapi.ErrorSource{Pointer: "/path/id"},
}})
}</span>
<span class="cov0" title="0">if err := h.svc.Delete(c.Context(), roleID); err != nil </span><span class="cov0" title="0">{
return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "Delete failed",
Detail: err.Error(),
}})
}</span>
<span class="cov0" title="0">return response.Write(c, fiber.StatusOK, jsonapi.Resource{
Type: "role_delete",
Attributes: map[string]any{
"deleted": true,
},
})</span>
}
// 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 <span class="cov0" title="0">{
idStr := c.Params("id")
roleID, err := uuidv7.ParseString(idStr)
if err != nil </span><span class="cov0" title="0">{
return response.WriteErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
Status: "422",
Title: "Validation error",
Detail: "id is invalid UUID",
Source: &amp;jsonapi.ErrorSource{Pointer: "/path/id"},
}})
}</span>
<span class="cov0" title="0">role, err := h.svc.GetByID(c.Context(), roleID)
if err != nil || role == nil </span><span class="cov0" title="0">{
return response.WriteErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{
Status: "404",
Title: "Not found",
Detail: "role not found",
}})
}</span>
<span class="cov0" title="0">return response.Write(c, fiber.StatusOK, roleResource(role))</span>
}
// 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 <span class="cov0" title="0">{
filterName := c.Query("filter[name]")
pageNumber := parseIntDefault(c.Query("page[number]"), 1)
pageSize := parseIntDefault(c.Query("page[size]"), 20)
if pageSize &gt; 100 </span><span class="cov0" title="0">{
pageSize = 100
}</span>
<span class="cov0" title="0">if pageNumber &lt; 1 </span><span class="cov0" title="0">{
pageNumber = 1
}</span>
<span class="cov0" title="0">offset := (pageNumber - 1) * pageSize
sort := c.Query("sort")
roles, total, err := h.svc.List(c.Context(), filterName, sort, pageSize, offset)
if err != nil </span><span class="cov0" title="0">{
return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "List failed",
Detail: err.Error(),
}})
}</span>
<span class="cov0" title="0">data := make([]dto.RoleResource, 0, len(roles))
for i := range roles </span><span class="cov0" title="0">{
data = append(data, roleResource(&amp;roles[i]))
}</span>
<span class="cov0" title="0">meta := map[string]any{
"page_number": pageNumber,
"page_size": pageSize,
"total": total,
}
return response.WriteWithMeta(c, fiber.StatusOK, data, meta)</span>
}
// 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 <span class="cov0" title="0">{
draw := parseIntDefault(c.Query("draw"), 1)
start := parseIntDefault(c.Query("start"), 0)
length := parseIntDefault(c.Query("length"), 20)
if length &gt; 100 </span><span class="cov0" title="0">{
length = 100
}</span>
<span class="cov0" title="0">search := c.Query("search[value]")
roles, total, err := h.svc.List(c.Context(), search, "", length, start)
if err != nil </span><span class="cov0" title="0">{
return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "List failed",
Detail: err.Error(),
}})
}</span>
<span class="cov0" title="0">data := make([]dto.RoleResource, 0, len(roles))
for i := range roles </span><span class="cov0" title="0">{
data = append(data, roleResource(&amp;roles[i]))
}</span>
<span class="cov0" title="0">meta := map[string]any{
"draw": draw,
"records_total": total,
"records_filtered": total,
}
return response.WriteWithMeta(c, fiber.StatusOK, data, meta)</span>
}
func roleResource(role *auth.Role) dto.RoleResource <span class="cov0" title="0">{
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),
},
}
}</span>
func parseIntDefault(v string, def int) int <span class="cov0" title="0">{
if v == "" </span><span class="cov0" title="0">{
return def
}</span>
<span class="cov0" title="0">n, err := strconv.Atoi(v)
if err != nil </span><span class="cov0" title="0">{
return def
}</span>
<span class="cov0" title="0">return n</span>
}
</pre>
<pre class="file" id="file38" style="display: none">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 <span class="cov0" title="0">{
return &amp;UserHandler{svc: svc}
}</span>
func (h *UserHandler) List(c *fiber.Ctx) error <span class="cov0" title="0">{
return response.WriteErrors(c, fiber.StatusNotImplemented, []jsonapi.ErrorObject{{
Status: "501",
Title: "Not implemented",
}})
}</span>
func (h *UserHandler) Get(c *fiber.Ctx) error <span class="cov0" title="0">{
return response.WriteErrors(c, fiber.StatusNotImplemented, []jsonapi.ErrorObject{{
Status: "501",
Title: "Not implemented",
}})
}</span>
</pre>
<pre class="file" id="file39" style="display: none">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 <span class="cov0" title="0">{
return c.Status(status).JSON(Document{Data: data})
}</span>
func WriteWithMeta(c *fiber.Ctx, status int, data any, meta any) error <span class="cov0" title="0">{
return c.Status(status).JSON(Document{Data: data, Meta: meta})
}</span>
func WriteErrors(c *fiber.Ctx, status int, errs []ErrorObject) error <span class="cov0" title="0">{
return c.Status(status).JSON(ErrorDocument{Errors: errs})
}</span>
</pre>
<pre class="file" id="file40" style="display: none">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 <span class="cov0" title="0">{
return func(c *fiber.Ctx) error </span><span class="cov0" title="0">{
if svc == nil </span><span class="cov0" title="0">{
return response.WriteErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
Status: "401",
Title: "Unauthorized",
Detail: "auth service not configured",
}})
}</span>
<span class="cov0" title="0">token := c.Cookies(svc.AccessCookieName())
userID, err := svc.ParseAccessToken(c.Context(), token)
if err != nil </span><span class="cov0" title="0">{
return response.WriteErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
Status: "401",
Title: "Unauthorized",
Detail: "invalid or expired token",
}})
}</span>
<span class="cov0" title="0">c.Locals("user_id", userID)
return c.Next()</span>
}
}
</pre>
<pre class="file" id="file41" style="display: none">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 <span class="cov0" title="0">{
v := validator.New()
v.RegisterTagNameFunc(func(fld reflect.StructField) string </span><span class="cov0" title="0">{
name := fld.Tag.Get("json")
if name == "" </span><span class="cov0" title="0">{
return fld.Name
}</span>
<span class="cov0" title="0">if idx := strings.Index(name, ","); idx != -1 </span><span class="cov0" title="0">{
return name[:idx]
}</span>
<span class="cov0" title="0">return name</span>
})
<span class="cov0" title="0">return &amp;Validator{validate: v}</span>
}
func (v *Validator) ValidateStruct(s any) []jsonapi.ErrorObject <span class="cov0" title="0">{
if err := v.validate.Struct(s); err != nil </span><span class="cov0" title="0">{
if ve, ok := err.(validator.ValidationErrors); ok </span><span class="cov0" title="0">{
return toJSONAPIErrors(ve)
}</span>
<span class="cov0" title="0">return []jsonapi.ErrorObject{{
Status: "400",
Title: "Invalid request",
Detail: err.Error(),
}}</span>
}
<span class="cov0" title="0">return nil</span>
}
func toJSONAPIErrors(ve validator.ValidationErrors) []jsonapi.ErrorObject <span class="cov0" title="0">{
errs := make([]jsonapi.ErrorObject, 0, len(ve))
for _, fe := range ve </span><span class="cov0" title="0">{
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: &amp;jsonapi.ErrorSource{Pointer: jsonPath},
})
}</span>
<span class="cov0" title="0">return errs</span>
}
</pre>
</div>
</body>
<script>
(function() {
var files = document.getElementById('files');
var visible;
files.addEventListener('change', onChange, false);
function select(part) {
if (visible)
visible.style.display = 'none';
visible = document.getElementById(part);
if (!visible)
return;
files.value = part;
visible.style.display = 'block';
location.hash = part;
}
function onChange() {
select(files.value);
window.scrollTo(0, 0);
}
if (location.hash != "") {
select(location.hash.substr(1));
}
if (!visible) {
select("file0");
}
})();
</script>
</html>