first
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
// Package config loads application configuration from environment variables.
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Database DatabaseConfig
|
||||
Redis RedisConfig
|
||||
JWT JWTConfig
|
||||
Server ServerConfig
|
||||
Seed SeedConfig
|
||||
Media MediaConfig
|
||||
}
|
||||
|
||||
type DatabaseConfig struct {
|
||||
URL string
|
||||
}
|
||||
|
||||
type RedisConfig struct {
|
||||
URL string
|
||||
}
|
||||
|
||||
type JWTConfig struct {
|
||||
Secret string
|
||||
AccessTTL time.Duration
|
||||
RefreshTTL time.Duration
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
Port string
|
||||
GinMode string
|
||||
CORSOrigin string
|
||||
CookieSecure bool
|
||||
}
|
||||
|
||||
type SeedConfig struct {
|
||||
AdminEmail string
|
||||
AdminPassword string
|
||||
}
|
||||
|
||||
// MediaConfig configures the pluggable media storage backend. Driver
|
||||
// selects "local" (files on disk, served via /uploads) or "s3" (any
|
||||
// S3-compatible object store). Only the fields relevant to the selected
|
||||
// driver need to be set.
|
||||
type MediaConfig struct {
|
||||
Driver string
|
||||
MaxUploadMB int64
|
||||
LocalDir string
|
||||
LocalBaseURL string
|
||||
|
||||
S3Bucket string
|
||||
S3Region string
|
||||
S3Endpoint string
|
||||
S3AccessKeyID string
|
||||
S3SecretKey string
|
||||
S3UsePathStyle bool
|
||||
S3PublicBaseURL string
|
||||
}
|
||||
|
||||
func Load() (*Config, error) {
|
||||
// The .env file lives at the repo root (next to docker-compose.yml),
|
||||
// but `go run` is commonly invoked from inside backend/. Try both so
|
||||
// either working directory picks it up; missing files are ignored.
|
||||
_ = godotenv.Load(".env")
|
||||
_ = godotenv.Load("../.env")
|
||||
|
||||
accessMinutes, err := strconv.Atoi(getEnv("ACCESS_TOKEN_TTL_MINUTES", "15"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid ACCESS_TOKEN_TTL_MINUTES: %w", err)
|
||||
}
|
||||
refreshHours, err := strconv.Atoi(getEnv("REFRESH_TOKEN_TTL_HOURS", "168"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid REFRESH_TOKEN_TTL_HOURS: %w", err)
|
||||
}
|
||||
|
||||
secret := os.Getenv("JWT_SECRET")
|
||||
if secret == "" || secret == "change-me-to-a-long-random-secret" {
|
||||
return nil, fmt.Errorf("JWT_SECRET must be set to a strong, unique value")
|
||||
}
|
||||
|
||||
dbURL := os.Getenv("DATABASE_URL")
|
||||
if dbURL == "" {
|
||||
return nil, fmt.Errorf("DATABASE_URL must be set")
|
||||
}
|
||||
redisURL := os.Getenv("REDIS_URL")
|
||||
if redisURL == "" {
|
||||
return nil, fmt.Errorf("REDIS_URL must be set")
|
||||
}
|
||||
|
||||
ginMode := getEnv("GIN_MODE", "debug")
|
||||
cookieSecure, err := strconv.ParseBool(getEnv("COOKIE_SECURE", strconv.FormatBool(ginMode != "debug")))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid COOKIE_SECURE: %w", err)
|
||||
}
|
||||
|
||||
mediaDriver := getEnv("MEDIA_STORAGE_DRIVER", "local")
|
||||
if mediaDriver != "local" && mediaDriver != "s3" {
|
||||
return nil, fmt.Errorf("invalid MEDIA_STORAGE_DRIVER %q: must be \"local\" or \"s3\"", mediaDriver)
|
||||
}
|
||||
maxUploadMB, err := strconv.ParseInt(getEnv("MEDIA_MAX_UPLOAD_MB", "10"), 10, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid MEDIA_MAX_UPLOAD_MB: %w", err)
|
||||
}
|
||||
s3UsePathStyle, err := strconv.ParseBool(getEnv("S3_USE_PATH_STYLE", "false"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid S3_USE_PATH_STYLE: %w", err)
|
||||
}
|
||||
if mediaDriver == "s3" {
|
||||
for _, key := range []string{"S3_BUCKET", "S3_REGION", "S3_ACCESS_KEY_ID", "S3_SECRET_ACCESS_KEY"} {
|
||||
if os.Getenv(key) == "" {
|
||||
return nil, fmt.Errorf("%s must be set when MEDIA_STORAGE_DRIVER=s3", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &Config{
|
||||
Database: DatabaseConfig{
|
||||
URL: dbURL,
|
||||
},
|
||||
Redis: RedisConfig{
|
||||
URL: redisURL,
|
||||
},
|
||||
JWT: JWTConfig{
|
||||
Secret: secret,
|
||||
AccessTTL: time.Duration(accessMinutes) * time.Minute,
|
||||
RefreshTTL: time.Duration(refreshHours) * time.Hour,
|
||||
},
|
||||
Server: ServerConfig{
|
||||
Port: getEnv("PORT", "8080"),
|
||||
GinMode: ginMode,
|
||||
CORSOrigin: getEnv("CORS_ORIGIN", "http://localhost:5173"),
|
||||
CookieSecure: cookieSecure,
|
||||
},
|
||||
Seed: SeedConfig{
|
||||
AdminEmail: os.Getenv("SEED_ADMIN_EMAIL"),
|
||||
AdminPassword: os.Getenv("SEED_ADMIN_PASSWORD"),
|
||||
},
|
||||
Media: MediaConfig{
|
||||
Driver: mediaDriver,
|
||||
MaxUploadMB: maxUploadMB,
|
||||
LocalDir: getEnv("MEDIA_LOCAL_DIR", "./uploads"),
|
||||
LocalBaseURL: getEnv("MEDIA_LOCAL_BASE_URL", "http://localhost:8080/uploads"),
|
||||
S3Bucket: os.Getenv("S3_BUCKET"),
|
||||
S3Region: os.Getenv("S3_REGION"),
|
||||
S3Endpoint: os.Getenv("S3_ENDPOINT"),
|
||||
S3AccessKeyID: os.Getenv("S3_ACCESS_KEY_ID"),
|
||||
S3SecretKey: os.Getenv("S3_SECRET_ACCESS_KEY"),
|
||||
S3UsePathStyle: s3UsePathStyle,
|
||||
S3PublicBaseURL: os.Getenv("S3_PUBLIC_BASE_URL"),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func getEnv(key, fallback string) string {
|
||||
if v, ok := os.LookupEnv(key); ok && v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Package db provides the PostgreSQL connection via GORM.
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
gormlogger "gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func Connect(databaseURL string, debug bool) (*gorm.DB, error) {
|
||||
logLevel := gormlogger.Silent
|
||||
if debug {
|
||||
logLevel = gormlogger.Warn
|
||||
}
|
||||
|
||||
db, err := gorm.Open(postgres.Open(databaseURL), &gorm.Config{
|
||||
Logger: gormlogger.Default.LogMode(logLevel),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connect postgres: %w", err)
|
||||
}
|
||||
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get sql.DB: %w", err)
|
||||
}
|
||||
if err := sqlDB.Ping(); err != nil {
|
||||
return nil, fmt.Errorf("ping postgres: %w", err)
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Package logger provides a minimal structured logger shared across modules.
|
||||
package logger
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
)
|
||||
|
||||
func New(debug bool) *slog.Logger {
|
||||
level := slog.LevelInfo
|
||||
if debug {
|
||||
level = slog.LevelDebug
|
||||
}
|
||||
handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: level})
|
||||
return slog.New(handler)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// Package middleware provides HTTP middleware shared across modules,
|
||||
// notably the admin/customer auth guards, CORS and rate limiting.
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"backend/internal/platform/security"
|
||||
)
|
||||
|
||||
const (
|
||||
ctxUserIDKey = "auth_user_id"
|
||||
ctxRoleKey = "auth_role"
|
||||
)
|
||||
|
||||
func extractBearerToken(c *gin.Context) (string, bool) {
|
||||
header := c.GetHeader("Authorization")
|
||||
if header == "" {
|
||||
return "", false
|
||||
}
|
||||
parts := strings.SplitN(header, " ", 2)
|
||||
if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") || parts[1] == "" {
|
||||
return "", false
|
||||
}
|
||||
return parts[1], true
|
||||
}
|
||||
|
||||
// requireAudience builds a guard that only accepts access tokens issued for
|
||||
// the given audience (admin or customer space) and role. Because the JWT
|
||||
// audience and the expected role are both checked, an admin-space token can
|
||||
// never authenticate a customer-only route, and vice versa.
|
||||
func requireAudience(secret string, aud security.Audience, role string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tokenString, ok := extractBearerToken(c)
|
||||
if !ok {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing bearer token"})
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := security.ParseAccessToken(secret, tokenString, aud)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired token"})
|
||||
return
|
||||
}
|
||||
|
||||
if claims.Role != role {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "insufficient role"})
|
||||
return
|
||||
}
|
||||
|
||||
userID, err := uuid.Parse(claims.Subject)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid subject claim"})
|
||||
return
|
||||
}
|
||||
|
||||
c.Set(ctxUserIDKey, userID)
|
||||
c.Set(ctxRoleKey, claims.Role)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// RequireAdmin protects admin-panel routes. Only tokens issued in the admin
|
||||
// audience with role "admin" pass.
|
||||
func RequireAdmin(secret string) gin.HandlerFunc {
|
||||
return requireAudience(secret, security.AudienceAdmin, "admin")
|
||||
}
|
||||
|
||||
// RequireCustomer protects storefront account routes. Only tokens issued in
|
||||
// the customer audience with role "customer" pass. Not mounted yet in this
|
||||
// phase (no customer-facing routes), but kept fully separate from
|
||||
// RequireAdmin so activating the customer module later never risks sharing
|
||||
// a session/cookie space with the admin panel.
|
||||
func RequireCustomer(secret string) gin.HandlerFunc {
|
||||
return requireAudience(secret, security.AudienceCustomer, "customer")
|
||||
}
|
||||
|
||||
// GetUserID returns the authenticated user's ID set by RequireAdmin/RequireCustomer.
|
||||
func GetUserID(c *gin.Context) (uuid.UUID, bool) {
|
||||
v, exists := c.Get(ctxUserIDKey)
|
||||
if !exists {
|
||||
return uuid.UUID{}, false
|
||||
}
|
||||
id, ok := v.(uuid.UUID)
|
||||
return id, ok
|
||||
}
|
||||
|
||||
// GetRole returns the authenticated user's role set by RequireAdmin/RequireCustomer.
|
||||
func GetRole(c *gin.Context) (string, bool) {
|
||||
v, exists := c.Get(ctxRoleKey)
|
||||
if !exists {
|
||||
return "", false
|
||||
}
|
||||
role, ok := v.(string)
|
||||
return role, ok
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// CORS allows only the configured frontend origin, with credentials enabled
|
||||
// (required for the httpOnly refresh-token cookie to be sent cross-origin
|
||||
// between the Vite dev server and the API in development).
|
||||
func CORS(allowedOrigin string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
origin := c.GetHeader("Origin")
|
||||
if origin != "" && origin == allowedOrigin {
|
||||
c.Header("Access-Control-Allow-Origin", origin)
|
||||
c.Header("Access-Control-Allow-Credentials", "true")
|
||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||
c.Header("Access-Control-Allow-Headers", "Authorization, Content-Type")
|
||||
c.Header("Vary", "Origin")
|
||||
}
|
||||
|
||||
if c.Request.Method == http.MethodOptions {
|
||||
c.AbortWithStatus(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
// PerIPRateLimiter limits requests per client IP, intended for sensitive
|
||||
// low-frequency endpoints like login (defense against credential stuffing
|
||||
// / brute force).
|
||||
type PerIPRateLimiter struct {
|
||||
mu sync.Mutex
|
||||
limiters map[string]*rate.Limiter
|
||||
r rate.Limit
|
||||
burst int
|
||||
}
|
||||
|
||||
func NewPerIPRateLimiter(requestsPerMinute int, burst int) *PerIPRateLimiter {
|
||||
l := &PerIPRateLimiter{
|
||||
limiters: make(map[string]*rate.Limiter),
|
||||
r: rate.Every(time.Minute / time.Duration(requestsPerMinute)),
|
||||
burst: burst,
|
||||
}
|
||||
go l.cleanupLoop()
|
||||
return l
|
||||
}
|
||||
|
||||
func (l *PerIPRateLimiter) getLimiter(ip string) *rate.Limiter {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
limiter, exists := l.limiters[ip]
|
||||
if !exists {
|
||||
limiter = rate.NewLimiter(l.r, l.burst)
|
||||
l.limiters[ip] = limiter
|
||||
}
|
||||
return limiter
|
||||
}
|
||||
|
||||
func (l *PerIPRateLimiter) cleanupLoop() {
|
||||
for {
|
||||
time.Sleep(10 * time.Minute)
|
||||
l.mu.Lock()
|
||||
for ip, limiter := range l.limiters {
|
||||
if limiter.Tokens() >= float64(l.burst) {
|
||||
delete(l.limiters, ip)
|
||||
}
|
||||
}
|
||||
l.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (l *PerIPRateLimiter) Middleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
ip := c.ClientIP()
|
||||
if !l.getLimiter(ip).Allow() {
|
||||
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "too many requests, try again later"})
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// Package redis provides the Redis client used for refresh-token/session state.
|
||||
package redis
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
func Connect(redisURL string) (*redis.Client, error) {
|
||||
opt, err := redis.ParseURL(redisURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse redis url: %w", err)
|
||||
}
|
||||
|
||||
client := redis.NewClient(opt)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := client.Ping(ctx).Err(); err != nil {
|
||||
return nil, fmt.Errorf("ping redis: %w", err)
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Audience separates admin-space tokens from customer-space tokens so a
|
||||
// token issued for one space can never be accepted by the other, even
|
||||
// though both are signed with the same server secret.
|
||||
type Audience string
|
||||
|
||||
const (
|
||||
AudienceAdmin Audience = "admin"
|
||||
AudienceCustomer Audience = "customer"
|
||||
)
|
||||
|
||||
// Claims are the JWT claims carried by access tokens issued by this platform.
|
||||
type Claims struct {
|
||||
Role string `json:"role"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// IssueAccessToken creates a signed, short-lived access token for the given
|
||||
// user, role and audience (admin or customer space).
|
||||
func IssueAccessToken(secret string, ttl time.Duration, userID uuid.UUID, role string, aud Audience) (string, error) {
|
||||
if role == "" {
|
||||
return "", fmt.Errorf("role must not be empty")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
claims := Claims{
|
||||
Role: role,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Subject: userID.String(),
|
||||
Audience: jwt.ClaimStrings{string(aud)},
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
NotBefore: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
|
||||
ID: uuid.NewString(),
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
signed, err := token.SignedString([]byte(secret))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("sign token: %w", err)
|
||||
}
|
||||
return signed, nil
|
||||
}
|
||||
|
||||
// ParseAccessToken verifies signature, algorithm and every claim explicitly:
|
||||
// - signing method must be HMAC (rejects "alg":"none" and any asymmetric swap attempt)
|
||||
// - exp/iat/nbf must be present and internally consistent (no expired/not-yet-valid tokens)
|
||||
// - aud must match exactly the expected audience (admin token can never pass as a customer token, or vice versa)
|
||||
// - sub must be present and a well-formed UUID (matches our user ID format)
|
||||
// - role must be present and non-empty
|
||||
// - jti must be present (reserved for future revocation lookups)
|
||||
func ParseAccessToken(secret string, tokenString string, expectedAud Audience) (*Claims, error) {
|
||||
claims := &Claims{}
|
||||
|
||||
token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
|
||||
}
|
||||
return []byte(secret), nil
|
||||
}, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name}))
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse token: %w", err)
|
||||
}
|
||||
if !token.Valid {
|
||||
return nil, errors.New("invalid token")
|
||||
}
|
||||
|
||||
if claims.ExpiresAt == nil {
|
||||
return nil, errors.New("missing exp claim")
|
||||
}
|
||||
if claims.IssuedAt == nil {
|
||||
return nil, errors.New("missing iat claim")
|
||||
}
|
||||
if claims.ID == "" {
|
||||
return nil, errors.New("missing jti claim")
|
||||
}
|
||||
if claims.Subject == "" {
|
||||
return nil, errors.New("missing sub claim")
|
||||
}
|
||||
if _, err := uuid.Parse(claims.Subject); err != nil {
|
||||
return nil, fmt.Errorf("invalid sub claim: %w", err)
|
||||
}
|
||||
if claims.Role == "" {
|
||||
return nil, errors.New("missing role claim")
|
||||
}
|
||||
|
||||
if len(claims.Audience) != 1 || claims.Audience[0] != string(expectedAud) {
|
||||
return nil, fmt.Errorf("token audience does not match expected space %q", expectedAud)
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// Package security provides password hashing and JWT issuing/verification
|
||||
// shared across auth modules.
|
||||
package security
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
)
|
||||
|
||||
const (
|
||||
argon2Time = 3
|
||||
argon2Memory = 64 * 1024 // 64 MB
|
||||
argon2Threads = 2
|
||||
argon2SaltLen = 16
|
||||
argon2KeyLen = 32
|
||||
)
|
||||
|
||||
// HashPassword hashes a plaintext password using argon2id and returns the
|
||||
// PHC-formatted string ($argon2id$v=19$m=...,t=...,p=...$salt$hash).
|
||||
func HashPassword(password string) (string, error) {
|
||||
salt := make([]byte, argon2SaltLen)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return "", fmt.Errorf("generate salt: %w", err)
|
||||
}
|
||||
|
||||
hash := argon2.IDKey([]byte(password), salt, argon2Time, argon2Memory, argon2Threads, argon2KeyLen)
|
||||
|
||||
encoded := fmt.Sprintf(
|
||||
"$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
|
||||
argon2.Version,
|
||||
argon2Memory,
|
||||
argon2Time,
|
||||
argon2Threads,
|
||||
base64.RawStdEncoding.EncodeToString(salt),
|
||||
base64.RawStdEncoding.EncodeToString(hash),
|
||||
)
|
||||
return encoded, nil
|
||||
}
|
||||
|
||||
// VerifyPassword checks a plaintext password against a PHC-formatted argon2id hash.
|
||||
func VerifyPassword(encodedHash, password string) (bool, error) {
|
||||
parts := strings.Split(encodedHash, "$")
|
||||
if len(parts) != 6 || parts[1] != "argon2id" {
|
||||
return false, fmt.Errorf("invalid hash format")
|
||||
}
|
||||
|
||||
var version int
|
||||
if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil {
|
||||
return false, fmt.Errorf("invalid hash version segment: %w", err)
|
||||
}
|
||||
if version != argon2.Version {
|
||||
return false, fmt.Errorf("unsupported argon2 version: %d", version)
|
||||
}
|
||||
|
||||
var memory uint32
|
||||
var time uint32
|
||||
var threads uint8
|
||||
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time, &threads); err != nil {
|
||||
return false, fmt.Errorf("invalid hash params segment: %w", err)
|
||||
}
|
||||
|
||||
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("decode salt: %w", err)
|
||||
}
|
||||
wantHash, err := base64.RawStdEncoding.DecodeString(parts[5])
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("decode hash: %w", err)
|
||||
}
|
||||
|
||||
gotHash := argon2.IDKey([]byte(password), salt, time, memory, threads, uint32(len(wantHash)))
|
||||
|
||||
if subtle.ConstantTimeCompare(gotHash, wantHash) == 1 {
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
Reference in New Issue
Block a user