ilk
This commit is contained in:
35
svc/internal/auditlog/events.go
Normal file
35
svc/internal/auditlog/events.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package auditlog
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
func ForcePasswordChangeStarted(
|
||||
ctx context.Context,
|
||||
targetUserID int64,
|
||||
reason string, // admin_reset | login_enforced
|
||||
) {
|
||||
Write(ActivityLog{
|
||||
UserID: IntUserIDToUUID(int(targetUserID)),
|
||||
ActionType: "force_password_change_started",
|
||||
ActionCategory: "auth",
|
||||
Description: "kullanıcı için zorunlu parola değişimi başlatıldı",
|
||||
IsSuccess: true,
|
||||
ErrorMessage: reason,
|
||||
})
|
||||
}
|
||||
|
||||
func ForcePasswordChangeCompleted(
|
||||
ctx context.Context,
|
||||
userID int64,
|
||||
source string, // reset_link | self_change | admin_reset
|
||||
) {
|
||||
Write(ActivityLog{
|
||||
UserID: IntUserIDToUUID(int(userID)),
|
||||
ActionType: "force_password_change_completed",
|
||||
ActionCategory: "auth",
|
||||
Description: "kullanıcı parolasını başarıyla güncelledi",
|
||||
IsSuccess: true,
|
||||
ErrorMessage: source,
|
||||
})
|
||||
}
|
||||
73
svc/internal/auditlog/helpers.go
Normal file
73
svc/internal/auditlog/helpers.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package auditlog
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
//
|
||||
// =======================================================
|
||||
// 🕵️ AUDIT LOG — HELPER FUNCTIONS
|
||||
// =======================================================
|
||||
// Bu dosya:
|
||||
// - UUID bekleyen kolonlar için int → uuid dönüşümü
|
||||
// - NULL-safe insert yardımcıları
|
||||
// içerir
|
||||
//
|
||||
|
||||
// -------------------------------------------------------
|
||||
// 🔹 IntUserIDToUUID
|
||||
// -------------------------------------------------------
|
||||
// int user_id → deterministic UUID
|
||||
// PostgreSQL uuid kolonu ile %100 uyumlu
|
||||
//
|
||||
// Aynı userID → her zaman aynı UUID
|
||||
func IntUserIDToUUID(userID int) string {
|
||||
if userID <= 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
sum := md5.Sum([]byte(fmt.Sprintf("bssapp-user-%d", userID)))
|
||||
|
||||
return fmt.Sprintf(
|
||||
"%x-%x-%x-%x-%x",
|
||||
sum[0:4],
|
||||
sum[4:6],
|
||||
sum[6:8],
|
||||
sum[8:10],
|
||||
sum[10:16],
|
||||
)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------
|
||||
// 🔹 nullIfZeroTime
|
||||
// -------------------------------------------------------
|
||||
// Zero time → NULL (SQL uyumlu)
|
||||
func nullIfZeroTime(t time.Time) interface{} {
|
||||
if t.IsZero() {
|
||||
return nil
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// -------------------------------------------------------
|
||||
// 🔹 nullIfZeroInt
|
||||
// -------------------------------------------------------
|
||||
// 0 → NULL (SQL uyumlu)
|
||||
func nullIfZeroInt(v int) interface{} {
|
||||
if v == 0 {
|
||||
return nil
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func Int64UserIDToUUID(id int64) string {
|
||||
return IntUserIDToUUID(int(id))
|
||||
}
|
||||
func nullIfEmpty(s string) any {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
30
svc/internal/auditlog/init.go
Normal file
30
svc/internal/auditlog/init.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package auditlog
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var (
|
||||
logQueue chan ActivityLog
|
||||
dbConn *sql.DB
|
||||
once sync.Once
|
||||
)
|
||||
|
||||
// Init → main.go içinden çağrılacak (tek sefer)
|
||||
func Init(db *sql.DB, bufferSize int) {
|
||||
log.Println("🟢 auditlog Init called, buffer:", bufferSize)
|
||||
|
||||
dbConn = db
|
||||
logQueue = make(chan ActivityLog, bufferSize)
|
||||
|
||||
go logWorker()
|
||||
}
|
||||
|
||||
// Optional: app kapanırken flush/stop istersen
|
||||
func Close() {
|
||||
if logQueue != nil {
|
||||
close(logQueue)
|
||||
}
|
||||
}
|
||||
37
svc/internal/auditlog/model.go
Normal file
37
svc/internal/auditlog/model.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package auditlog
|
||||
|
||||
import "time"
|
||||
|
||||
type ActivityLog struct {
|
||||
// identity
|
||||
UserID string // UUID (auth)
|
||||
DfUsrID int64 // DF user id (mk_dfusr.id)
|
||||
|
||||
Username string
|
||||
RoleCode string
|
||||
|
||||
// action
|
||||
ActionType string
|
||||
ActionCategory string
|
||||
ActionTarget string
|
||||
Description string
|
||||
|
||||
// tech
|
||||
IpAddress string
|
||||
UserAgent string
|
||||
SessionID string
|
||||
|
||||
// timing
|
||||
RequestStartedAt time.Time
|
||||
RequestFinishedAt time.Time
|
||||
DurationMs int
|
||||
HttpStatus int
|
||||
|
||||
// result
|
||||
IsSuccess bool
|
||||
ErrorMessage string
|
||||
TargetDfUsrID int64
|
||||
TargetUsername string
|
||||
ChangeBefore any // map[string]any
|
||||
ChangeAfter any
|
||||
}
|
||||
141
svc/internal/auditlog/worker.go
Normal file
141
svc/internal/auditlog/worker.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package auditlog
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
)
|
||||
|
||||
func toJSONB(v any) any {
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
// JSON marshal hata olursa log’u bozmayalım
|
||||
log.Println("⚠️ auditlog json marshal error:", err)
|
||||
return nil
|
||||
}
|
||||
return b // pq jsonb için []byte kabul eder
|
||||
}
|
||||
|
||||
func logWorker() {
|
||||
log.Println("🟢 auditlog worker STARTED")
|
||||
|
||||
for entry := range logQueue {
|
||||
|
||||
// ---------- DFUSR_ID ----------
|
||||
var dfusrID any
|
||||
if entry.DfUsrID > 0 {
|
||||
dfusrID = entry.DfUsrID
|
||||
} else {
|
||||
dfusrID = nil
|
||||
}
|
||||
|
||||
// ---------- USERNAME ----------
|
||||
var username any
|
||||
if entry.Username != "" {
|
||||
username = entry.Username
|
||||
} else {
|
||||
username = nil
|
||||
}
|
||||
|
||||
// ---------- ROLE CODE (SNAPSHOT) ----------
|
||||
roleCode := entry.RoleCode
|
||||
if roleCode == "" {
|
||||
roleCode = "public"
|
||||
}
|
||||
|
||||
// ---------- TARGET ----------
|
||||
var targetDfUsrID any
|
||||
if entry.TargetDfUsrID > 0 {
|
||||
targetDfUsrID = entry.TargetDfUsrID
|
||||
} else {
|
||||
targetDfUsrID = nil
|
||||
}
|
||||
targetUsername := nullIfEmpty(entry.TargetUsername)
|
||||
|
||||
log.Printf(
|
||||
"🧾 auditlog INSERT | actor_dfusr=%v actor_user=%v role=%s %s %s target=%v",
|
||||
dfusrID,
|
||||
username,
|
||||
roleCode,
|
||||
entry.ActionCategory,
|
||||
entry.ActionTarget,
|
||||
targetDfUsrID,
|
||||
)
|
||||
|
||||
_, err := dbConn.Exec(`
|
||||
INSERT INTO mk_user_activity_log (
|
||||
log_id,
|
||||
dfusr_id,
|
||||
username,
|
||||
role_code,
|
||||
|
||||
action_type,
|
||||
action_category,
|
||||
action_target,
|
||||
description,
|
||||
|
||||
ip_address,
|
||||
user_agent,
|
||||
session_id,
|
||||
|
||||
request_started_at,
|
||||
request_finished_at,
|
||||
duration_ms,
|
||||
http_status,
|
||||
|
||||
is_success,
|
||||
error_message,
|
||||
|
||||
-- ✅ NEW
|
||||
target_dfusr_id,
|
||||
target_username,
|
||||
change_before,
|
||||
change_after,
|
||||
|
||||
created_at
|
||||
) VALUES (
|
||||
gen_random_uuid(),
|
||||
$1,$2,$3,
|
||||
$4,$5,$6,$7,
|
||||
$8,$9,$10,
|
||||
$11,$12,$13,$14,
|
||||
$15,$16,
|
||||
$17,$18,$19,$20,
|
||||
now()
|
||||
)
|
||||
`,
|
||||
dfusrID,
|
||||
username,
|
||||
roleCode,
|
||||
|
||||
entry.ActionType,
|
||||
entry.ActionCategory,
|
||||
entry.ActionTarget,
|
||||
entry.Description,
|
||||
|
||||
entry.IpAddress,
|
||||
entry.UserAgent,
|
||||
entry.SessionID,
|
||||
|
||||
nullIfZeroTime(entry.RequestStartedAt),
|
||||
nullIfZeroTime(entry.RequestFinishedAt),
|
||||
nullIfZeroInt(entry.DurationMs),
|
||||
nullIfZeroInt(entry.HttpStatus),
|
||||
|
||||
entry.IsSuccess,
|
||||
entry.ErrorMessage,
|
||||
|
||||
// ✅ NEW
|
||||
targetDfUsrID,
|
||||
targetUsername,
|
||||
toJSONB(entry.ChangeBefore),
|
||||
toJSONB(entry.ChangeAfter),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
log.Println("❌ auditlog insert error:", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
25
svc/internal/auditlog/writer.go
Normal file
25
svc/internal/auditlog/writer.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package auditlog
|
||||
|
||||
import "context"
|
||||
|
||||
func Write(log ActivityLog) {
|
||||
if logQueue == nil {
|
||||
return // sistem henüz init edilmediyse sessizce çık
|
||||
}
|
||||
|
||||
select {
|
||||
case logQueue <- log:
|
||||
// kuyruğa alındı
|
||||
default:
|
||||
// kuyruk dolu → drop edilir, ana akış bozulmaz
|
||||
}
|
||||
}
|
||||
func Enqueue(ctx context.Context, al ActivityLog) {
|
||||
|
||||
select {
|
||||
case logQueue <- al:
|
||||
// ok
|
||||
default:
|
||||
// queue dolu → drop
|
||||
}
|
||||
}
|
||||
36
svc/internal/authz/context.go
Normal file
36
svc/internal/authz/context.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package authz
|
||||
|
||||
import "context"
|
||||
|
||||
type scopeKey string
|
||||
|
||||
const (
|
||||
CtxDeptCodesKey scopeKey = "authz.dept_codes"
|
||||
CtxPiyasaCodesKey scopeKey = "authz.piyasa_codes"
|
||||
)
|
||||
|
||||
func WithDeptCodes(ctx context.Context, codes []string) context.Context {
|
||||
return context.WithValue(ctx, CtxDeptCodesKey, codes)
|
||||
}
|
||||
|
||||
func WithPiyasaCodes(ctx context.Context, codes []string) context.Context {
|
||||
return context.WithValue(ctx, CtxPiyasaCodesKey, codes)
|
||||
}
|
||||
|
||||
func GetDeptCodesFromCtx(ctx context.Context) []string {
|
||||
if v := ctx.Value(CtxDeptCodesKey); v != nil {
|
||||
if codes, ok := v.([]string); ok {
|
||||
return codes
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetPiyasaCodesFromCtx(ctx context.Context) []string {
|
||||
if v := ctx.Value(CtxPiyasaCodesKey); v != nil {
|
||||
if codes, ok := v.([]string); ok {
|
||||
return codes
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
32
svc/internal/authz/mssql.go
Normal file
32
svc/internal/authz/mssql.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package authz
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func BuildMSSQLPiyasaFilter(
|
||||
ctx context.Context,
|
||||
column string,
|
||||
) string {
|
||||
|
||||
codes := GetPiyasaCodesFromCtx(ctx)
|
||||
|
||||
if len(codes) == 0 {
|
||||
return "1=1"
|
||||
|
||||
}
|
||||
|
||||
var quoted []string
|
||||
|
||||
for _, c := range codes {
|
||||
quoted = append(quoted, "'"+c+"'")
|
||||
}
|
||||
|
||||
return fmt.Sprintf(
|
||||
"%s IN (%s)",
|
||||
column,
|
||||
strings.Join(quoted, ","),
|
||||
)
|
||||
}
|
||||
24
svc/internal/authz/mssql_helpers.go
Normal file
24
svc/internal/authz/mssql_helpers.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package authz
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func BuildINClause(column string, codes []string) string {
|
||||
if len(codes) == 0 {
|
||||
return "1=0"
|
||||
}
|
||||
var quoted []string
|
||||
for _, c := range codes {
|
||||
c = strings.TrimSpace(strings.ToUpper(c))
|
||||
if c == "" {
|
||||
continue
|
||||
}
|
||||
quoted = append(quoted, "'"+c+"'")
|
||||
}
|
||||
if len(quoted) == 0 {
|
||||
return "1=0"
|
||||
}
|
||||
return fmt.Sprintf("%s IN (%s)", column, strings.Join(quoted, ","))
|
||||
}
|
||||
74
svc/internal/authz/piyasa_repo.go
Normal file
74
svc/internal/authz/piyasa_repo.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package authz
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// =====================================================
|
||||
// 🧠 PIYASA CACHE (USER → CODES)
|
||||
// =====================================================
|
||||
|
||||
var (
|
||||
piyasaCache = make(map[int][]string)
|
||||
piyasaMu sync.RWMutex
|
||||
)
|
||||
|
||||
// =====================================================
|
||||
// 📌 GET USER PIYASA CODES (CACHED)
|
||||
// =====================================================
|
||||
|
||||
func GetUserPiyasaCodes(pg *sql.DB, userID int) ([]string, error) {
|
||||
|
||||
// -----------------------------
|
||||
// CACHE READ
|
||||
// -----------------------------
|
||||
piyasaMu.RLock()
|
||||
if it, ok := piyasaCache[userID]; ok {
|
||||
piyasaMu.RUnlock()
|
||||
return it, nil
|
||||
}
|
||||
piyasaMu.RUnlock()
|
||||
|
||||
// -----------------------------
|
||||
// DB QUERY
|
||||
// -----------------------------
|
||||
rows, err := pg.Query(`
|
||||
SELECT piyasa_code
|
||||
FROM dfusr_piyasa
|
||||
WHERE dfusr_id = $1
|
||||
AND is_allowed = true
|
||||
`, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pg piyasa query error: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var code string
|
||||
if err := rows.Scan(&code); err == nil {
|
||||
out = append(out, code)
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// CACHE WRITE
|
||||
// -----------------------------
|
||||
piyasaMu.Lock()
|
||||
piyasaCache[userID] = out
|
||||
piyasaMu.Unlock()
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// 🧹 CLEAR USER PIYASA CACHE
|
||||
// =====================================================
|
||||
|
||||
func ClearPiyasaCache(userID int) {
|
||||
piyasaMu.Lock()
|
||||
defer piyasaMu.Unlock()
|
||||
delete(piyasaCache, userID)
|
||||
}
|
||||
50
svc/internal/mailer/config.go
Normal file
50
svc/internal/mailer/config.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package mailer
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Host string
|
||||
Port int
|
||||
Username string
|
||||
Password string
|
||||
From string
|
||||
StartTLS bool
|
||||
}
|
||||
|
||||
func ConfigFromEnv() (Config, error) {
|
||||
var cfg Config
|
||||
|
||||
cfg.Host = strings.TrimSpace(os.Getenv("SMTP_HOST"))
|
||||
cfg.Username = strings.TrimSpace(os.Getenv("SMTP_USERNAME"))
|
||||
cfg.Password = os.Getenv("SMTP_PASSWORD")
|
||||
cfg.From = strings.TrimSpace(os.Getenv("SMTP_FROM"))
|
||||
|
||||
portStr := strings.TrimSpace(os.Getenv("SMTP_PORT"))
|
||||
if portStr == "" {
|
||||
cfg.Port = 587
|
||||
} else {
|
||||
p, err := strconv.Atoi(portStr)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.Port = p
|
||||
}
|
||||
|
||||
startTLS := strings.TrimSpace(os.Getenv("SMTP_STARTTLS"))
|
||||
if startTLS == "" {
|
||||
cfg.StartTLS = true
|
||||
} else {
|
||||
cfg.StartTLS = strings.EqualFold(startTLS, "true") || startTLS == "1"
|
||||
}
|
||||
|
||||
// minimal validation
|
||||
if cfg.Host == "" || cfg.Username == "" || cfg.Password == "" || cfg.From == "" || cfg.Port <= 0 {
|
||||
return Config{}, ErrInvalidConfig
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
270
svc/internal/mailer/graph_mailer.go
Normal file
270
svc/internal/mailer/graph_mailer.go
Normal file
@@ -0,0 +1,270 @@
|
||||
package mailer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/oauth2/clientcredentials"
|
||||
)
|
||||
|
||||
type GraphMailer struct {
|
||||
httpClient *http.Client
|
||||
from string // noreply@baggi.com.tr
|
||||
replyTo string // opsiyonel
|
||||
}
|
||||
|
||||
func NewGraphMailer() (*GraphMailer, error) {
|
||||
tenantID := strings.TrimSpace(os.Getenv("AZURE_TENANT_ID"))
|
||||
clientID := strings.TrimSpace(os.Getenv("AZURE_CLIENT_ID"))
|
||||
clientSecret := strings.TrimSpace(os.Getenv("AZURE_CLIENT_SECRET"))
|
||||
from := strings.TrimSpace(os.Getenv("MAIL_FROM"))
|
||||
replyTo := strings.TrimSpace(os.Getenv("MAIL_REPLY_TO")) // opsiyonel
|
||||
|
||||
if tenantID == "" || clientID == "" || clientSecret == "" || from == "" {
|
||||
return nil, fmt.Errorf("azure graph mailer env missing (AZURE_TENANT_ID/AZURE_CLIENT_ID/AZURE_CLIENT_SECRET/MAIL_FROM)")
|
||||
}
|
||||
|
||||
conf := clientcredentials.Config{
|
||||
ClientID: clientID,
|
||||
ClientSecret: clientSecret,
|
||||
TokenURL: fmt.Sprintf("https://login.microsoftonline.com/%s/oauth2/v2.0/token", tenantID),
|
||||
Scopes: []string{"https://graph.microsoft.com/.default"},
|
||||
}
|
||||
|
||||
httpClient := conf.Client(context.Background())
|
||||
httpClient.Timeout = 25 * time.Second
|
||||
|
||||
log.Printf("✉️ Graph Mailer hazır (App-only token) | from=%s", from)
|
||||
|
||||
return &GraphMailer{
|
||||
httpClient: httpClient,
|
||||
from: from,
|
||||
replyTo: replyTo,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (g *GraphMailer) Send(ctx context.Context, msg Message) error {
|
||||
start := time.Now()
|
||||
|
||||
// ---------- validate ----------
|
||||
cleanTo := make([]string, 0, len(msg.To))
|
||||
for _, t := range msg.To {
|
||||
t = strings.TrimSpace(t)
|
||||
if t != "" {
|
||||
cleanTo = append(cleanTo, t)
|
||||
}
|
||||
}
|
||||
if len(cleanTo) == 0 {
|
||||
return fmt.Errorf("recipient missing")
|
||||
}
|
||||
subject := strings.TrimSpace(msg.Subject)
|
||||
if subject == "" {
|
||||
return fmt.Errorf("subject missing")
|
||||
}
|
||||
|
||||
// internal-safe adjustments
|
||||
isInternal := allRecipientsInDomain(cleanTo, "baggi.com.tr")
|
||||
if isInternal && !strings.HasPrefix(subject, "[BSSApp]") {
|
||||
subject = "[BSSApp] " + subject
|
||||
}
|
||||
|
||||
html := strings.TrimSpace(msg.BodyHTML)
|
||||
text := strings.TrimSpace(msg.Body)
|
||||
|
||||
// Eğer sadece HTML geldiyse text üret (spam skorunu düşürür)
|
||||
if text == "" && html != "" {
|
||||
text = stripHTMLVerySimple(html)
|
||||
}
|
||||
// Eğer sadece text geldiyse basit HTML üret
|
||||
if html == "" && text != "" {
|
||||
html = "<pre style=\"font-family:Segoe UI, Arial, sans-serif; white-space:pre-wrap;\">" +
|
||||
htmlEscape(text) + "</pre>"
|
||||
}
|
||||
if html == "" {
|
||||
return fmt.Errorf("body missing (Body or BodyHTML)")
|
||||
}
|
||||
|
||||
log.Printf("✉️ [MAIL] SEND START | from=%s | to=%v | internal=%v | subject=%s", g.from, cleanTo, isInternal, subject)
|
||||
|
||||
// ---------- build recipients ----------
|
||||
type recipient struct {
|
||||
EmailAddress struct {
|
||||
Address string `json:"address"`
|
||||
} `json:"emailAddress"`
|
||||
}
|
||||
toRecipients := make([]recipient, 0, len(cleanTo))
|
||||
for _, m := range cleanTo {
|
||||
r := recipient{}
|
||||
r.EmailAddress.Address = m
|
||||
toRecipients = append(toRecipients, r)
|
||||
}
|
||||
|
||||
// ---------- headers to reduce auto-phish/spam signals ----------
|
||||
headers := []map[string]string{
|
||||
{
|
||||
"name": "X-Mailer",
|
||||
"value": "BSSApp Graph Mailer",
|
||||
},
|
||||
{
|
||||
"name": "X-BSSApp-Internal",
|
||||
"value": strconv.FormatBool(isInternal),
|
||||
},
|
||||
}
|
||||
|
||||
// replyTo (opsiyonel)
|
||||
var replyToRecipients []recipient
|
||||
if strings.TrimSpace(g.replyTo) != "" {
|
||||
rt := recipient{}
|
||||
rt.EmailAddress.Address = strings.TrimSpace(g.replyTo)
|
||||
replyToRecipients = []recipient{rt}
|
||||
}
|
||||
|
||||
// ---------- Graph payload ----------
|
||||
message := map[string]any{
|
||||
"subject": subject,
|
||||
"body": map[string]string{
|
||||
"contentType": "HTML",
|
||||
"content": buildHTML(html, text, isInternal),
|
||||
},
|
||||
"toRecipients": toRecipients,
|
||||
"internetMessageHeaders": headers,
|
||||
"importance": "normal",
|
||||
}
|
||||
|
||||
// replyTo SADECE doluysa ekle
|
||||
if len(replyToRecipients) > 0 {
|
||||
message["replyTo"] = replyToRecipients
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"message": message,
|
||||
"saveToSentItems": true,
|
||||
}
|
||||
|
||||
b, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("json marshal: %w", err)
|
||||
}
|
||||
|
||||
url := fmt.Sprintf(
|
||||
"https://graph.microsoft.com/v1.0/users/%s/sendMail",
|
||||
g.from,
|
||||
)
|
||||
|
||||
req, err := http.NewRequestWithContext(
|
||||
ctx,
|
||||
http.MethodPost,
|
||||
url,
|
||||
bytes.NewBuffer(b),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("new request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
res, err := g.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("graph request: %w", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode >= 300 {
|
||||
bodyBytes, _ := io.ReadAll(res.Body)
|
||||
log.Printf(
|
||||
"❌ [MAIL] SEND FAILED | status=%s | body=%s",
|
||||
res.Status,
|
||||
string(bodyBytes),
|
||||
)
|
||||
return fmt.Errorf("graph send mail failed: %s", res.Status)
|
||||
}
|
||||
|
||||
log.Printf(
|
||||
"✅ [MAIL] SEND OK | to=%v | duration=%s",
|
||||
cleanTo,
|
||||
time.Since(start),
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
func allRecipientsInDomain(to []string, domain string) bool {
|
||||
domain = strings.ToLower(strings.TrimSpace(domain))
|
||||
for _, addr := range to {
|
||||
addr = strings.ToLower(strings.TrimSpace(addr))
|
||||
if !strings.HasSuffix(addr, "@"+domain) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func buildHTML(htmlBody, textBody string, internal bool) string {
|
||||
// Internal ise daha sade, daha az “marketing-like”
|
||||
if internal {
|
||||
return `
|
||||
<div style="font-family:Segoe UI, Arial, sans-serif; font-size:14px; color:#1f1f1f;">
|
||||
` + htmlBody + `
|
||||
<hr style="border:none;border-top:1px solid #e5e5e5;margin:16px 0;" />
|
||||
<div style="font-size:12px;color:#666;">
|
||||
Bu e-posta BSSApp sistemi tarafından otomatik oluşturulmuştur.
|
||||
</div>
|
||||
</div>`
|
||||
}
|
||||
|
||||
// External
|
||||
return `
|
||||
<div style="font-family:Segoe UI, Arial, sans-serif; font-size:14px; color:#1f1f1f;">
|
||||
` + htmlBody + `
|
||||
</div>`
|
||||
}
|
||||
|
||||
// Çok basit “html -> text” (tam değil ama yeterli)
|
||||
func stripHTMLVerySimple(s string) string {
|
||||
s = strings.ReplaceAll(s, "<br>", "\n")
|
||||
s = strings.ReplaceAll(s, "<br/>", "\n")
|
||||
s = strings.ReplaceAll(s, "<br />", "\n")
|
||||
s = strings.ReplaceAll(s, "</p>", "\n\n")
|
||||
// kaba temizlik
|
||||
for {
|
||||
i := strings.Index(s, "<")
|
||||
j := strings.Index(s, ">")
|
||||
if i >= 0 && j > i {
|
||||
s = s[:i] + s[j+1:]
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
func htmlEscape(s string) string {
|
||||
replacer := strings.NewReplacer(
|
||||
"&", "&",
|
||||
"<", "<",
|
||||
">", ">",
|
||||
"\"", """,
|
||||
"'", "'",
|
||||
)
|
||||
return replacer.Replace(s)
|
||||
}
|
||||
func (g *GraphMailer) SendMail(to string, subject string, html string) error {
|
||||
msg := Message{
|
||||
To: []string{to},
|
||||
Subject: subject,
|
||||
BodyHTML: html,
|
||||
}
|
||||
|
||||
// context burada internal olarak veriliyor
|
||||
return g.Send(context.Background(), msg)
|
||||
}
|
||||
148
svc/internal/mailer/mailer.go
Normal file
148
svc/internal/mailer/mailer.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package mailer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrInvalidConfig = errors.New("invalid smtp config")
|
||||
|
||||
type Mailer struct {
|
||||
cfg Config
|
||||
}
|
||||
|
||||
type Message struct {
|
||||
To []string
|
||||
Subject string
|
||||
Body string
|
||||
BodyHTML string
|
||||
}
|
||||
|
||||
func New(cfg Config) *Mailer {
|
||||
return &Mailer{cfg: cfg}
|
||||
}
|
||||
|
||||
func (m *Mailer) Send(ctx context.Context, msg Message) error {
|
||||
if len(msg.To) == 0 {
|
||||
return errors.New("recipient missing")
|
||||
}
|
||||
if strings.TrimSpace(msg.Subject) == "" {
|
||||
return errors.New("subject missing")
|
||||
}
|
||||
|
||||
// timeout kontrolü (ctx)
|
||||
deadline, ok := ctx.Deadline()
|
||||
if !ok {
|
||||
// default timeout
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, 20*time.Second)
|
||||
defer cancel()
|
||||
deadline, _ = ctx.Deadline()
|
||||
}
|
||||
timeout := time.Until(deadline)
|
||||
if timeout <= 0 {
|
||||
return context.DeadlineExceeded
|
||||
}
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", m.cfg.Host, m.cfg.Port)
|
||||
|
||||
dialer := net.Dialer{Timeout: timeout}
|
||||
conn, err := dialer.DialContext(ctx, "tcp", addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("smtp dial: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
c, err := smtp.NewClient(conn, m.cfg.Host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("smtp client: %w", err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
// STARTTLS
|
||||
if m.cfg.StartTLS {
|
||||
tlsCfg := &tls.Config{
|
||||
ServerName: m.cfg.Host,
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}
|
||||
if err := c.StartTLS(tlsCfg); err != nil {
|
||||
return fmt.Errorf("starttls: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// AUTH
|
||||
auth := smtp.PlainAuth("", m.cfg.Username, m.cfg.Password, m.cfg.Host)
|
||||
if err := c.Auth(auth); err != nil {
|
||||
return fmt.Errorf("smtp auth: %w", err)
|
||||
}
|
||||
|
||||
// MAIL FROM
|
||||
if err := c.Mail(m.cfg.From); err != nil {
|
||||
return fmt.Errorf("mail from: %w", err)
|
||||
}
|
||||
|
||||
// RCPT TO
|
||||
for _, rcpt := range msg.To {
|
||||
rcpt = strings.TrimSpace(rcpt)
|
||||
if rcpt == "" {
|
||||
continue
|
||||
}
|
||||
if err := c.Rcpt(rcpt); err != nil {
|
||||
return fmt.Errorf("rcpt %s: %w", rcpt, err)
|
||||
}
|
||||
}
|
||||
|
||||
w, err := c.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("data: %w", err)
|
||||
}
|
||||
|
||||
// basit mime
|
||||
contentType := "text/plain; charset=UTF-8"
|
||||
body := msg.Body
|
||||
if strings.TrimSpace(msg.BodyHTML) != "" {
|
||||
contentType = "text/html; charset=UTF-8"
|
||||
body = msg.BodyHTML
|
||||
}
|
||||
|
||||
raw := buildMIME(m.cfg.From, msg.To, msg.Subject, contentType, body)
|
||||
|
||||
_, writeErr := w.Write([]byte(raw))
|
||||
closeErr := w.Close()
|
||||
|
||||
if writeErr != nil {
|
||||
return fmt.Errorf("write body: %w", writeErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
return fmt.Errorf("close body: %w", closeErr)
|
||||
}
|
||||
|
||||
if err := c.Quit(); err != nil {
|
||||
return fmt.Errorf("quit: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildMIME(from string, to []string, subject, contentType, body string) string {
|
||||
// Subject UTF-8 basit hali (gerekirse sonra MIME encoded-word ekleriz)
|
||||
headers := []string{
|
||||
"From: " + from,
|
||||
"To: " + strings.Join(to, ", "),
|
||||
"Subject: " + subject,
|
||||
"MIME-Version: 1.0",
|
||||
"Content-Type: " + contentType,
|
||||
"",
|
||||
}
|
||||
return strings.Join(headers, "\r\n") + "\r\n" + body + "\r\n"
|
||||
}
|
||||
|
||||
type MailerInterface interface {
|
||||
Send(ctx context.Context, msg Message) error
|
||||
}
|
||||
18
svc/internal/mailer/password_reset.go
Normal file
18
svc/internal/mailer/password_reset.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package mailer
|
||||
|
||||
import "fmt"
|
||||
|
||||
func (m *GraphMailer) SendPasswordResetMail(toEmail string, resetURL string) error {
|
||||
subject := "Parola Sıfırlama"
|
||||
|
||||
html := fmt.Sprintf(`
|
||||
<p>Merhaba,</p>
|
||||
<p>Parolanızı sıfırlamak için aşağıdaki bağlantıya tıklayın:</p>
|
||||
<p>
|
||||
<a href="%s">%s</a>
|
||||
</p>
|
||||
<p>Bu bağlantı <strong>30 dakika</strong> geçerlidir ve tek kullanımlıktır.</p>
|
||||
`, resetURL, resetURL)
|
||||
|
||||
return m.SendMail(toEmail, subject, html)
|
||||
}
|
||||
11
svc/internal/security/errors.go
Normal file
11
svc/internal/security/errors.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package security
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrPasswordTooShort = errors.New("password must be at least 8 characters")
|
||||
ErrPasswordUpper = errors.New("password must contain an uppercase letter")
|
||||
ErrPasswordLower = errors.New("password must contain a lowercase letter")
|
||||
ErrPasswordDigit = errors.New("password must contain a digit")
|
||||
ErrPasswordSpecial = errors.New("password must contain a special character")
|
||||
)
|
||||
35
svc/internal/security/password_policy.go
Normal file
35
svc/internal/security/password_policy.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
reUpper = regexp.MustCompile(`[A-Z]`)
|
||||
reLower = regexp.MustCompile(`[a-z]`)
|
||||
reDigit = regexp.MustCompile(`[0-9]`)
|
||||
reSpecial = regexp.MustCompile(`[^A-Za-z0-9]`)
|
||||
)
|
||||
|
||||
func ValidatePassword(pw string) error {
|
||||
pw = strings.TrimSpace(pw)
|
||||
|
||||
if len(pw) < 8 {
|
||||
return errors.New("Parola en az 8 karakter olmalı")
|
||||
}
|
||||
if !reUpper.MatchString(pw) {
|
||||
return errors.New("Parola en az 1 büyük harf içermeli")
|
||||
}
|
||||
if !reLower.MatchString(pw) {
|
||||
return errors.New("Parola en az 1 küçük harf içermeli")
|
||||
}
|
||||
if !reDigit.MatchString(pw) {
|
||||
return errors.New("Parola en az 1 rakam içermeli")
|
||||
}
|
||||
if !reSpecial.MatchString(pw) {
|
||||
return errors.New("Parola en az 1 özel karakter içermeli")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
13
svc/internal/security/password_reset.go
Normal file
13
svc/internal/security/password_reset.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"os"
|
||||
)
|
||||
|
||||
func BuildResetURL(token string) string {
|
||||
base := os.Getenv("FRONTEND_URL")
|
||||
if base == "" {
|
||||
base = "http://localhost:9000"
|
||||
}
|
||||
return base + "/password-reset/" + token
|
||||
}
|
||||
23
svc/internal/security/refresh_token.go
Normal file
23
svc/internal/security/refresh_token.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
)
|
||||
|
||||
func GenerateRefreshToken() (plain string, hash string, err error) {
|
||||
b := make([]byte, 32) // 256 bit
|
||||
if _, err = rand.Read(b); err != nil {
|
||||
return
|
||||
}
|
||||
plain = hex.EncodeToString(b)
|
||||
sum := sha256.Sum256([]byte(plain))
|
||||
hash = hex.EncodeToString(sum[:])
|
||||
return
|
||||
}
|
||||
|
||||
func HashRefreshToken(plain string) string {
|
||||
sum := sha256.Sum256([]byte(plain))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
26
svc/internal/security/reset_token.go
Normal file
26
svc/internal/security/reset_token.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
)
|
||||
|
||||
func GenerateResetToken() (plain string, hash string, err error) {
|
||||
b := make([]byte, 32) // 256 bit
|
||||
if _, err = rand.Read(b); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
plain = hex.EncodeToString(b)
|
||||
|
||||
sum := sha256.Sum256([]byte(plain))
|
||||
hash = hex.EncodeToString(sum[:])
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func HashToken(plain string) string {
|
||||
sum := sha256.Sum256([]byte(plain))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
Reference in New Issue
Block a user