5166 lines
192 KiB
HTML
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) -> cookies
|
|
// @description
|
|
// @description Auth Flow (TOTP setup):
|
|
// @description 1) POST /api/v1/auth/totp/setup -> secret + otpauth_url (enabled=false)
|
|
// @description 2) POST /api/v1/auth/totp/confirm -> enabled=true
|
|
// @description
|
|
// @description Auth Flow (Microsoft Entra SSO):
|
|
// @description 1) GET /api/v1/auth/microsoft/login -> redirect to Microsoft
|
|
// @description 2) Microsoft redirects to /api/v1/auth/microsoft/callback
|
|
// @description 3) Backend sets cookies then redirects to AUTH_SSO_SUCCESS_REDIRECT
|
|
// @BasePath /
|
|
func main() <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 != "" && msCfg.ClientID != "" && msCfg.ClientSecret != "" && 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 <-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 = &swag.Spec{
|
|
Version: "1.0",
|
|
Host: "",
|
|
BasePath: "/",
|
|
Schemes: []string{},
|
|
Title: "Wucher API",
|
|
Description: "Wucher backend API (JSON:API).\n\nAuth Flow (Email/Password):\n1) POST /api/v1/auth/register\n2) Check email, open verify link (GET /api/v1/auth/verify-email)\n3) POST /api/v1/auth/login\n- If TOTP enabled: response 202 with challenge_token\n4) POST /api/v1/auth/totp/verify (challenge_token + code) -> cookies\n\nAuth Flow (TOTP setup):\n1) POST /api/v1/auth/totp/setup -> secret + otpauth_url (enabled=false)\n2) POST /api/v1/auth/totp/confirm -> enabled=true\n\nAuth Flow (Microsoft Entra SSO):\n1) GET /api/v1/auth/microsoft/login -> redirect to Microsoft\n2) Microsoft redirects to /api/v1/auth/microsoft/callback\n3) Backend sets cookies then redirects to AUTH_SSO_SUCCESS_REDIRECT",
|
|
InfoInstanceName: "swagger",
|
|
SwaggerTemplate: docTemplate,
|
|
LeftDelim: "{{",
|
|
RightDelim: "}}",
|
|
}
|
|
|
|
func init() <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(&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 &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 &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 >> 40)
|
|
out[1] = byte(ms >> 32)
|
|
out[2] = byte(ms >> 24)
|
|
out[3] = byte(ms >> 16)
|
|
out[4] = byte(ms >> 8)
|
|
out[5] = byte(ms)
|
|
|
|
// Version (0111) in high 4 bits of byte 6
|
|
out[6] = (r[0] & 0x0f) | 0x70
|
|
out[7] = r[1]
|
|
|
|
// Variant (10) in high 2 bits of byte 8
|
|
out[8] = (r[2] & 0x3f) | 0x80
|
|
out[9] = r[3]
|
|
out[10] = r[4]
|
|
out[11] = r[5]
|
|
out[12] = r[6]
|
|
out[13] = r[7]
|
|
out[14] = r[8]
|
|
out[15] = r[9]
|
|
|
|
return out, nil</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 &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(&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 &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(&user).Error
|
|
if errors.Is(err, gorm.ErrRecordNotFound) </span><span class="cov0" title="0">{
|
|
return nil, nil
|
|
}</span>
|
|
<span class="cov0" title="0">return &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(&auth.User{}).
|
|
Where("id = ?", id).
|
|
Update("email_verified_at", &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(&identity).Error
|
|
if errors.Is(err, gorm.ErrRecordNotFound) </span><span class="cov0" title="0">{
|
|
return nil, nil
|
|
}</span>
|
|
<span class="cov0" title="0">return &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(&totp).Error
|
|
if errors.Is(err, gorm.ErrRecordNotFound) </span><span class="cov0" title="0">{
|
|
return nil, nil
|
|
}</span>
|
|
<span class="cov0" title="0">return &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(&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(&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), &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(
|
|
&auth.Role{},
|
|
&auth.Permission{},
|
|
&auth.User{},
|
|
&auth.RolePermission{},
|
|
&auth.UserIdentity{},
|
|
&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 &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(&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(&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 &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(&role).Error
|
|
if errors.Is(err, gorm.ErrRecordNotFound) </span><span class="cov0" title="0">{
|
|
return nil, nil
|
|
}</span>
|
|
<span class="cov0" title="0">return &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(&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(&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 > 0 </span><span class="cov0" title="0">{
|
|
query = query.Limit(limit).Offset(offset)
|
|
}</span>
|
|
|
|
<span class="cov0" title="0">if err := query.Find(&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 &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(&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 &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(&u).Error
|
|
if errors.Is(err, gorm.ErrRecordNotFound) </span><span class="cov0" title="0">{
|
|
return nil, nil
|
|
}</span>
|
|
<span class="cov0" title="0">return &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 &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) < 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(&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 &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 &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 := &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 != "" && 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 -> upsert identity -> 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 && 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 := &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 := &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 := &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 &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 < 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, &job); err != nil </span><span class="cov0" title="0">{
|
|
return nil, err
|
|
}</span>
|
|
<span class="cov0" title="0">return &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 &MicrosoftSSOClient{
|
|
cfg: cfg,
|
|
client: &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 < 200 || resp.StatusCode >= 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, &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) < 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, &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 &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 &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) < 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 &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 := &net.Dialer{Timeout: 10 * time.Second}
|
|
tlsCfg := &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 &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 &AuthHandler{
|
|
svc: svc,
|
|
validate: validators.New(),
|
|
}
|
|
}</span>
|
|
|
|
// Register godoc
|
|
// @Summary Register user with email & password
|
|
// @Description Creates user (default role: admin) and sends verification link to email.
|
|
// @Description Next: open verify link -> then login.
|
|
// @Tags Auth
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body dto.RegisterRequest true "JSON:API Register request"
|
|
// @Success 201 {object} dto.UserResponse
|
|
// @Failure 422 {object} dto.ErrorResponse
|
|
// @Failure 400 {object} dto.ErrorResponse
|
|
// @Router /api/v1/auth/register [post]
|
|
func (h *AuthHandler) Register(c *fiber.Ctx) error <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(&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 & 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(&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: &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 && 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(&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: &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(&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: &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(&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(&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: &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(&fiber.Cookie{
|
|
Name: svc.AccessCookieName(),
|
|
Value: tokens.AccessToken,
|
|
HTTPOnly: true,
|
|
Secure: svc.CookieSecure(),
|
|
SameSite: parseSameSite(svc.CookieSameSite()),
|
|
Domain: svc.CookieDomain(),
|
|
Path: "/",
|
|
MaxAge: int(svc.AccessTTL().Seconds()),
|
|
})
|
|
c.Cookie(&fiber.Cookie{
|
|
Name: svc.RefreshCookieName(),
|
|
Value: tokens.RefreshToken,
|
|
HTTPOnly: true,
|
|
Secure: svc.CookieSecure(),
|
|
SameSite: parseSameSite(svc.CookieSameSite()),
|
|
Domain: svc.CookieDomain(),
|
|
Path: "/api/v1/auth/refresh",
|
|
MaxAge: int(svc.RefreshTTL().Seconds()),
|
|
})
|
|
}</span>
|
|
|
|
func clearAuthCookies(c *fiber.Ctx, svc *service.AuthService) <span class="cov0" title="0">{
|
|
c.Cookie(&fiber.Cookie{
|
|
Name: svc.AccessCookieName(),
|
|
Value: "",
|
|
HTTPOnly: true,
|
|
Secure: svc.CookieSecure(),
|
|
SameSite: parseSameSite(svc.CookieSameSite()),
|
|
Domain: svc.CookieDomain(),
|
|
Path: "/",
|
|
MaxAge: -1,
|
|
})
|
|
c.Cookie(&fiber.Cookie{
|
|
Name: svc.RefreshCookieName(),
|
|
Value: "",
|
|
HTTPOnly: true,
|
|
Secure: svc.CookieSecure(),
|
|
SameSite: parseSameSite(svc.CookieSameSite()),
|
|
Domain: svc.CookieDomain(),
|
|
Path: "/api/v1/auth/refresh",
|
|
MaxAge: -1,
|
|
})
|
|
}</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 &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 &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(&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 := &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: &jsonapi.ErrorSource{Pointer: "/path/id"},
|
|
}})
|
|
}</span>
|
|
|
|
<span class="cov0" title="0">var req dto.RoleUpdateRequest
|
|
if err := c.BodyParser(&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: &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: &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 > 100 </span><span class="cov0" title="0">{
|
|
pageSize = 100
|
|
}</span>
|
|
<span class="cov0" title="0">if pageNumber < 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(&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 > 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(&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 &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 &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: &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>
|