first commit
This commit is contained in:
158
accounts/accounts_test.go
Normal file
158
accounts/accounts_test.go
Normal file
@@ -0,0 +1,158 @@
|
||||
package accounts
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ─── normalizeRole ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestNormalizeRole_Admin(t *testing.T) {
|
||||
if got := normalizeRole("admin"); got != RoleAdmin {
|
||||
t.Fatalf("expected %q, got %q", RoleAdmin, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeRole_User(t *testing.T) {
|
||||
if got := normalizeRole("user"); got != RoleUser {
|
||||
t.Fatalf("expected %q, got %q", RoleUser, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeRole_Unknown(t *testing.T) {
|
||||
for _, input := range []string{"", "superuser", "moderator", "ADMIN"} {
|
||||
if got := normalizeRole(input); got != RoleUser {
|
||||
t.Fatalf("input %q: expected %q fallback, got %q", input, RoleUser, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── roleFromUser ───────────────────────────────────────────────────────────
|
||||
|
||||
func TestRoleFromUser_Admin(t *testing.T) {
|
||||
u := User{IsAdmin: true}
|
||||
if got := roleFromUser(u); got != RoleAdmin {
|
||||
t.Fatalf("expected admin role, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoleFromUser_RegularUser(t *testing.T) {
|
||||
u := User{IsAdmin: false}
|
||||
if got := roleFromUser(u); got != RoleUser {
|
||||
t.Fatalf("expected user role, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── GenerateTokens / ParseAccessToken / ParseRefreshToken ──────────────────
|
||||
|
||||
func TestGenerateAndParse_RoundTrip(t *testing.T) {
|
||||
t.Setenv("JWT_SECRET", "test-access-secret-xyz")
|
||||
t.Setenv("JWT_REFRESH_SECRET", "test-refresh-secret-xyz")
|
||||
|
||||
access, refresh, err := GenerateTokens(99, RoleUser)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateTokens error: %v", err)
|
||||
}
|
||||
|
||||
uid, err := ParseAccessToken(access)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseAccessToken error: %v", err)
|
||||
}
|
||||
if uid != 99 {
|
||||
t.Fatalf("expected user_id 99, got %d", uid)
|
||||
}
|
||||
|
||||
ruid, err := ParseRefreshToken(refresh)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseRefreshToken error: %v", err)
|
||||
}
|
||||
if ruid != 99 {
|
||||
t.Fatalf("expected refresh user_id 99, got %d", ruid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateTokens_MissingSecretsError(t *testing.T) {
|
||||
t.Setenv("JWT_SECRET", "")
|
||||
t.Setenv("JWT_REFRESH_SECRET", "")
|
||||
|
||||
if _, _, err := GenerateTokens(1, RoleUser); err == nil {
|
||||
t.Fatal("expected error when JWT secrets are missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAccessToken_TamperedTokenFails(t *testing.T) {
|
||||
t.Setenv("JWT_SECRET", "my-secret")
|
||||
t.Setenv("JWT_REFRESH_SECRET", "my-refresh")
|
||||
|
||||
_, err := ParseAccessToken("this.is.notavalidtoken")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for tampered token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRefreshToken_WrongSecretFails(t *testing.T) {
|
||||
t.Setenv("JWT_SECRET", "secret-a")
|
||||
t.Setenv("JWT_REFRESH_SECRET", "secret-b")
|
||||
|
||||
access, _, err := GenerateTokens(1, RoleUser)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateTokens error: %v", err)
|
||||
}
|
||||
|
||||
// Access token'ı refresh secret ile parse etmeye çalışmak başarısız olmalı
|
||||
_, err = ParseRefreshToken(access)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when parsing access token with refresh secret")
|
||||
}
|
||||
}
|
||||
|
||||
// ─── parseAccessClaims – role claim içeriği ──────────────────────────────────
|
||||
|
||||
func TestParseAccessClaims_ContainsRole(t *testing.T) {
|
||||
t.Setenv("JWT_SECRET", "test-secret")
|
||||
t.Setenv("JWT_REFRESH_SECRET", "test-refresh")
|
||||
|
||||
access, _, err := GenerateTokens(7, RoleAdmin)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateTokens error: %v", err)
|
||||
}
|
||||
|
||||
claims, err := parseAccessClaims(access)
|
||||
if err != nil {
|
||||
t.Fatalf("parseAccessClaims error: %v", err)
|
||||
}
|
||||
|
||||
if claims.Role != RoleAdmin {
|
||||
t.Fatalf("expected role %q, got %q", RoleAdmin, claims.Role)
|
||||
}
|
||||
if claims.UserID != 7 {
|
||||
t.Fatalf("expected user_id 7, got %d", claims.UserID)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── User model – ApiToken süresi ───────────────────────────────────────────
|
||||
|
||||
func TestUser_ApiTokenExpiresAt_NilMeansNeverExpires(t *testing.T) {
|
||||
u := User{ApiTokenExpiresAt: nil}
|
||||
if u.ApiTokenExpiresAt != nil {
|
||||
t.Fatal("nil ApiTokenExpiresAt must remain nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUser_ApiTokenExpiresAt_CanBeSet(t *testing.T) {
|
||||
exp := time.Now().Add(24 * time.Hour)
|
||||
u := User{ApiTokenExpiresAt: &exp}
|
||||
if u.ApiTokenExpiresAt == nil {
|
||||
t.Fatal("ApiTokenExpiresAt should not be nil after assignment")
|
||||
}
|
||||
if !u.ApiTokenExpiresAt.Equal(exp) {
|
||||
t.Fatalf("expected %v, got %v", exp, *u.ApiTokenExpiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUser_IsAdminDefaultFalse(t *testing.T) {
|
||||
u := User{}
|
||||
if u.IsAdmin {
|
||||
t.Fatal("zero-value User must not be admin")
|
||||
}
|
||||
}
|
||||
254
accounts/handlers.go
Normal file
254
accounts/handlers.go
Normal file
@@ -0,0 +1,254 @@
|
||||
package accounts
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"goimgApi/configs"
|
||||
)
|
||||
|
||||
type AuthReq struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// Register godoc
|
||||
// @Summary Register a new user
|
||||
// @Description Register a new user with email and password
|
||||
// @Tags Auth
|
||||
// @Accept multipart/form-data
|
||||
// @Produce json
|
||||
// @Param email formData string true "Email address"
|
||||
// @Param password formData string true "Password (min 6 chars)"
|
||||
// @Success 201 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]interface{}
|
||||
// @Router /auth/register [post]
|
||||
func Register(c fiber.Ctx) error {
|
||||
email := c.FormValue("email")
|
||||
password := c.FormValue("password")
|
||||
|
||||
if email == "" || len(password) < 6 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Email required and password min 6 chars"})
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to hash password"})
|
||||
}
|
||||
|
||||
user := User{
|
||||
Email: email,
|
||||
PasswordHash: string(hash),
|
||||
}
|
||||
|
||||
if err := configs.DB.Create(&user).Error; err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Email might be already in use"})
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(fiber.Map{"message": "User registered", "user_id": user.ID})
|
||||
}
|
||||
|
||||
// Login godoc
|
||||
// @Summary Login
|
||||
// @Description Authenticate user and get JWT
|
||||
// @Tags Auth
|
||||
// @Accept multipart/form-data
|
||||
// @Produce json
|
||||
// @Param email formData string true "Email address"
|
||||
// @Param password formData string true "Password"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]interface{}
|
||||
// @Router /auth/login [post]
|
||||
func Login(c fiber.Ctx) error {
|
||||
email := c.FormValue("email")
|
||||
password := c.FormValue("password")
|
||||
|
||||
var user User
|
||||
if err := configs.DB.Where("email = ?", email).First(&user).Error; err != nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid credentials"})
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid credentials"})
|
||||
}
|
||||
|
||||
role := roleFromUser(user)
|
||||
access, refresh, err := GenerateTokens(user.ID, role)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Token generation failed"})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"access_token": access,
|
||||
"refresh_token": refresh,
|
||||
"user": fiber.Map{
|
||||
"id": user.ID,
|
||||
"email": user.Email,
|
||||
"role": role,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
type RefreshReq struct {
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
|
||||
// Refresh godoc
|
||||
// @Summary Refresh JWT
|
||||
// @Description Get a new access token using a valid refresh token
|
||||
// @Tags Auth
|
||||
// @Accept multipart/form-data
|
||||
// @Produce json
|
||||
// @Param refresh_token formData string true "Refresh token"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 401 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]interface{}
|
||||
// @Failure 500 {object} map[string]interface{}
|
||||
// @Router /auth/refresh [post]
|
||||
func Refresh(c fiber.Ctx) error {
|
||||
refreshToken := c.FormValue("refresh_token")
|
||||
|
||||
if refreshToken == "" {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Refresh token required"})
|
||||
}
|
||||
|
||||
userID, err := ParseRefreshToken(refreshToken)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid or expired refresh token"})
|
||||
}
|
||||
|
||||
var user User
|
||||
if err := configs.DB.First(&user, userID).Error; err != nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "User not found"})
|
||||
}
|
||||
|
||||
acc, ref, err := GenerateTokens(userID, roleFromUser(user))
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Token generation failed"})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"access_token": acc,
|
||||
"refresh_token": ref,
|
||||
})
|
||||
}
|
||||
|
||||
// JWTMiddleware extracts user_id from access token
|
||||
func JWTMiddleware(c fiber.Ctx) error {
|
||||
authHeader := c.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Unauthorized"})
|
||||
}
|
||||
|
||||
// Swagger UI esnekliği için "Bearer " prefix'ini isteğe bağlı kılıyoruz.
|
||||
tokenStr := strings.TrimSpace(strings.TrimPrefix(authHeader, "Bearer"))
|
||||
claims, err := parseAccessClaims(tokenStr)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid or expired token"})
|
||||
}
|
||||
|
||||
// Set userID context
|
||||
c.Locals("user_id", claims.UserID)
|
||||
c.Locals("role", claims.Role)
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
// AdminMiddleware ensures the logged-in user is an admin
|
||||
func AdminMiddleware(c fiber.Ctx) error {
|
||||
userIDVal := c.Locals("user_id")
|
||||
if userIDVal == nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Unauthorized"})
|
||||
}
|
||||
|
||||
var user User
|
||||
if err := configs.DB.First(&user, userIDVal).Error; err != nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "User not found"})
|
||||
}
|
||||
|
||||
if !user.IsAdmin {
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Admin privileges required"})
|
||||
}
|
||||
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
type CreateApiTokenReq struct {
|
||||
ExpiresInDays int `json:"expires_in_days"`
|
||||
}
|
||||
|
||||
// CreateApiToken godoc
|
||||
// @Summary Create API Token
|
||||
// @Description Creates an API Token for a user (Admin ONLY).
|
||||
// @Tags Admin
|
||||
// @Accept multipart/form-data
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param id path int true "User ID"
|
||||
// @Param expires_in_days formData int false "Expiration in days (0 or omit for never)"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 401 {object} map[string]interface{}
|
||||
// @Failure 403 {object} map[string]interface{}
|
||||
// @Failure 404 {object} map[string]interface{}
|
||||
// @Router /admin/users/{id}/api-token [post]
|
||||
func CreateApiToken(c fiber.Ctx) error {
|
||||
targetID := c.Params("id")
|
||||
|
||||
expiresInDays := 0
|
||||
if f := c.FormValue("expires_in_days"); f != "" {
|
||||
if val, err := strconv.Atoi(f); err == nil {
|
||||
expiresInDays = val
|
||||
}
|
||||
}
|
||||
// Fallback eklendi: Eger FormData gelmediyse Query'den parametreyi deneriz
|
||||
if q := c.Query("expires_in_days"); q != "" {
|
||||
if val, err := strconv.Atoi(q); err == nil {
|
||||
expiresInDays = val
|
||||
}
|
||||
}
|
||||
|
||||
var user User
|
||||
if err := configs.DB.First(&user, targetID).Error; err != nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "User not found"})
|
||||
}
|
||||
|
||||
// Generate and update
|
||||
token := uuid.New().String()
|
||||
user.ApiToken = token
|
||||
|
||||
if expiresInDays > 0 {
|
||||
exp := time.Now().Add(time.Duration(expiresInDays) * 24 * time.Hour)
|
||||
user.ApiTokenExpiresAt = &exp
|
||||
} else {
|
||||
user.ApiTokenExpiresAt = nil
|
||||
}
|
||||
|
||||
if err := configs.DB.Save(&user).Error; err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to save API token"})
|
||||
}
|
||||
|
||||
responseMap := fiber.Map{
|
||||
"message": "API token created successfully",
|
||||
"api_token": token,
|
||||
}
|
||||
|
||||
if user.ApiTokenExpiresAt != nil {
|
||||
responseMap["expires_at"] = user.ApiTokenExpiresAt
|
||||
} else {
|
||||
responseMap["expires_at"] = "never"
|
||||
}
|
||||
|
||||
return c.JSON(responseMap)
|
||||
}
|
||||
|
||||
func roleFromUser(user User) string {
|
||||
if user.IsAdmin {
|
||||
return RoleAdmin
|
||||
}
|
||||
|
||||
return RoleUser
|
||||
}
|
||||
111
accounts/jwt.go
Normal file
111
accounts/jwt.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package accounts
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
type jwtClaims struct {
|
||||
UserID uint `json:"user_id"`
|
||||
Role string `json:"role"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
const (
|
||||
RoleAdmin = "admin"
|
||||
RoleUser = "user"
|
||||
)
|
||||
|
||||
func GenerateTokens(userID uint, role string) (string, string, error) {
|
||||
accessSecret := os.Getenv("JWT_SECRET")
|
||||
refreshSecret := os.Getenv("JWT_REFRESH_SECRET")
|
||||
if accessSecret == "" || refreshSecret == "" {
|
||||
return "", "", fmt.Errorf("JWT secrets are not set")
|
||||
}
|
||||
|
||||
normalizedRole := normalizeRole(role)
|
||||
|
||||
// Access Token
|
||||
accessClaims := jwtClaims{
|
||||
UserID: userID,
|
||||
Role: normalizedRole,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
},
|
||||
}
|
||||
accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims)
|
||||
accessString, err := accessToken.SignedString([]byte(accessSecret))
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
// Refresh Token
|
||||
refreshClaims := jwtClaims{
|
||||
UserID: userID,
|
||||
Role: normalizedRole,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(7 * 24 * time.Hour)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
},
|
||||
}
|
||||
refreshToken := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims)
|
||||
refreshString, err := refreshToken.SignedString([]byte(refreshSecret))
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
return accessString, refreshString, nil
|
||||
}
|
||||
|
||||
func ParseAccessToken(tokenString string) (uint, error) {
|
||||
secret := os.Getenv("JWT_SECRET")
|
||||
claims, err := parseTokenClaims(tokenString, secret)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return claims.UserID, nil
|
||||
}
|
||||
|
||||
func ParseRefreshToken(tokenString string) (uint, error) {
|
||||
secret := os.Getenv("JWT_REFRESH_SECRET")
|
||||
claims, err := parseTokenClaims(tokenString, secret)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return claims.UserID, nil
|
||||
}
|
||||
|
||||
func parseAccessClaims(tokenString string) (*jwtClaims, error) {
|
||||
secret := os.Getenv("JWT_SECRET")
|
||||
return parseTokenClaims(tokenString, secret)
|
||||
}
|
||||
|
||||
func parseTokenClaims(tokenString, secret string) (*jwtClaims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenString, &jwtClaims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
return []byte(secret), nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if claims, ok := token.Claims.(*jwtClaims); ok && token.Valid {
|
||||
claims.Role = normalizeRole(claims.Role)
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("invalid token")
|
||||
}
|
||||
|
||||
func normalizeRole(role string) string {
|
||||
switch role {
|
||||
case RoleAdmin:
|
||||
return RoleAdmin
|
||||
default:
|
||||
return RoleUser
|
||||
}
|
||||
}
|
||||
54
accounts/jwt_test.go
Normal file
54
accounts/jwt_test.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package accounts
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestGenerateTokensIncludesRoleClaim(t *testing.T) {
|
||||
t.Setenv("JWT_SECRET", "test-access-secret")
|
||||
t.Setenv("JWT_REFRESH_SECRET", "test-refresh-secret")
|
||||
|
||||
accessToken, refreshToken, err := GenerateTokens(42, RoleAdmin)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateTokens returned error: %v", err)
|
||||
}
|
||||
|
||||
accessClaims, err := parseAccessClaims(accessToken)
|
||||
if err != nil {
|
||||
t.Fatalf("parseAccessClaims returned error: %v", err)
|
||||
}
|
||||
|
||||
if accessClaims.UserID != 42 {
|
||||
t.Fatalf("expected access user id 42, got %d", accessClaims.UserID)
|
||||
}
|
||||
|
||||
if accessClaims.Role != RoleAdmin {
|
||||
t.Fatalf("expected access role %q, got %q", RoleAdmin, accessClaims.Role)
|
||||
}
|
||||
|
||||
refreshUserID, err := ParseRefreshToken(refreshToken)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseRefreshToken returned error: %v", err)
|
||||
}
|
||||
|
||||
if refreshUserID != 42 {
|
||||
t.Fatalf("expected refresh user id 42, got %d", refreshUserID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateTokensNormalizesUnknownRoleToUser(t *testing.T) {
|
||||
t.Setenv("JWT_SECRET", "test-access-secret")
|
||||
t.Setenv("JWT_REFRESH_SECRET", "test-refresh-secret")
|
||||
|
||||
accessToken, _, err := GenerateTokens(7, "superuser")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateTokens returned error: %v", err)
|
||||
}
|
||||
|
||||
accessClaims, err := parseAccessClaims(accessToken)
|
||||
if err != nil {
|
||||
t.Fatalf("parseAccessClaims returned error: %v", err)
|
||||
}
|
||||
|
||||
if accessClaims.Role != RoleUser {
|
||||
t.Fatalf("expected normalized role %q, got %q", RoleUser, accessClaims.Role)
|
||||
}
|
||||
}
|
||||
19
accounts/models.go
Normal file
19
accounts/models.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package accounts
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Email string `gorm:"uniqueIndex;not null" json:"email"`
|
||||
PasswordHash string `gorm:"not null" json:"-"`
|
||||
IsAdmin bool `gorm:"default:false" json:"is_admin"`
|
||||
ApiToken string `gorm:"uniqueIndex" json:"api_token"`
|
||||
ApiTokenExpiresAt *time.Time `json:"api_token_expires_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
}
|
||||
48
accounts/models/account.go
Normal file
48
accounts/models/account.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
gorm.Model
|
||||
UserName string `json:"username" gorm:"type:varchar(255)"`
|
||||
Email string `gorm:"uniqueIndex;not null;type:varchar(255)" json:"email"`
|
||||
Password string `json:"-" gorm:"type:varchar(255)"` // Password shouldn't be returned in JSON
|
||||
EmailVerified *bool `gorm:"default:false" json:"email_verified"` // default false for email/password registration
|
||||
EmailVerifyToken string `gorm:"index;type:varchar(255)" json:"-"`
|
||||
EmailVerifiedAt *time.Time `json:"email_verified_at,omitempty"`
|
||||
IsAdmin *bool `gorm:"default:false" json:"is_admin"`
|
||||
SocialAccounts []SocialAccount `gorm:"foreignKey:UserID" json:"social_accounts,omitempty"`
|
||||
Profile []Profile `gorm:"foreignKey:UserID" json:"profiles,omitempty"`
|
||||
}
|
||||
|
||||
// IsEmailVerified Email Veriyf i False Döndürüyor
|
||||
func (u *User) IsEmailVerified() bool {
|
||||
if u.EmailVerified == nil {
|
||||
return false
|
||||
}
|
||||
return *u.EmailVerified
|
||||
}
|
||||
|
||||
// SocialAccount model structure
|
||||
type SocialAccount struct {
|
||||
gorm.Model
|
||||
UserID uint64 `gorm:"type:bigint unsigned;not null;index" json:"user_id"`
|
||||
Provider string `gorm:"type:varchar(32);not null;uniqueIndex:idx_social_provider_identity" json:"provider"` // google, github
|
||||
ProviderID string `gorm:"type:varchar(191);not null;uniqueIndex:idx_social_provider_identity" json:"provider_id"`
|
||||
Email string `json:"email" gorm:"type:varchar(255)"`
|
||||
Name string `json:"name,omitempty" gorm:"type:varchar(255)"` // Full name from provider
|
||||
AvatarURL string `json:"avatar_url,omitempty" gorm:"type:varchar(255)"` // Avatar URL from provider
|
||||
|
||||
}
|
||||
type Profile struct {
|
||||
gorm.Model
|
||||
UserID uint64 `gorm:"type:bigint unsigned;not null;index" json:"user_id"`
|
||||
AvatarURL string `json:"avatar_url,omitempty" gorm:"type:varchar(255)"` // Avatar URL from provider
|
||||
FirstName string `json:"first_name" gorm:"type:varchar(255)"` // Full name from provider
|
||||
LastName string `json:"last_name" gorm:"type:varchar(255)"` // Full name from provider
|
||||
|
||||
}
|
||||
65
accounts/models/models_test.go
Normal file
65
accounts/models/models_test.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ─── User.IsEmailVerified ────────────────────────────────────────────────────
|
||||
|
||||
func TestIsEmailVerified_NilPointer(t *testing.T) {
|
||||
u := &User{}
|
||||
if u.IsEmailVerified() {
|
||||
t.Fatal("expected false when EmailVerified is nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsEmailVerified_False(t *testing.T) {
|
||||
v := false
|
||||
u := &User{EmailVerified: &v}
|
||||
if u.IsEmailVerified() {
|
||||
t.Fatal("expected false when EmailVerified is &false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsEmailVerified_True(t *testing.T) {
|
||||
v := true
|
||||
u := &User{EmailVerified: &v}
|
||||
if !u.IsEmailVerified() {
|
||||
t.Fatal("expected true when EmailVerified is &true")
|
||||
}
|
||||
}
|
||||
|
||||
// ─── User struct tag doğrulamaları ──────────────────────────────────────────
|
||||
|
||||
func TestUser_PasswordHiddenFromJSON(t *testing.T) {
|
||||
f, ok := reflect.TypeOf(User{}).FieldByName("Password")
|
||||
if !ok {
|
||||
t.Fatal("User has no Password field")
|
||||
}
|
||||
if tag := f.Tag.Get("json"); tag != "-" {
|
||||
t.Fatalf("expected Password json tag to be \"-\", got %q", tag)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUser_EmailUniqueIndexTagSet(t *testing.T) {
|
||||
f, ok := reflect.TypeOf(User{}).FieldByName("Email")
|
||||
if !ok {
|
||||
t.Fatal("User has no Email field")
|
||||
}
|
||||
if gormTag := f.Tag.Get("gorm"); gormTag == "" {
|
||||
t.Fatal("Email field has no gorm tag")
|
||||
}
|
||||
}
|
||||
|
||||
// ─── RefreshToken alanları ───────────────────────────────────────────────────
|
||||
|
||||
func TestRefreshToken_HasRequiredFields(t *testing.T) {
|
||||
typ := reflect.TypeOf(RefreshToken{})
|
||||
required := []string{"UserID", "TokenID", "TokenHash", "TokenFingerprint", "ExpiresAt", "Revoked"}
|
||||
for _, name := range required {
|
||||
if _, ok := typ.FieldByName(name); !ok {
|
||||
t.Errorf("RefreshToken missing required field: %s", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
26
accounts/models/token.go
Normal file
26
accounts/models/token.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// RefreshToken represents a server-side record of issued refresh tokens
|
||||
// to support rotation, revocation and reuse detection.
|
||||
type RefreshToken struct {
|
||||
gorm.Model
|
||||
UserID uint `gorm:"not null;index" json:"user_id"`
|
||||
TokenID string `gorm:"type:varchar(128);not null;uniqueIndex" json:"token_id"`
|
||||
// TokenHash is SHA-256 hex of the refresh token string (64 chars).
|
||||
// Stored instead of the raw token for security, while still allowing debug/lookup.
|
||||
TokenHash string `gorm:"type:char(64);index" json:"token_hash"`
|
||||
// TokenFingerprint is a masked representation (e.g. first6...last4) to help operators
|
||||
// visually correlate DB rows with logs without storing full token.
|
||||
TokenFingerprint string `gorm:"type:varchar(32);index" json:"token_fingerprint"`
|
||||
ExpiresAt time.Time `gorm:"index" json:"expires_at"`
|
||||
Revoked bool `gorm:"index" json:"revoked"`
|
||||
ReplacedByTokenID string `gorm:"type:varchar(128)" json:"replaced_by_token_id"`
|
||||
UserAgent string `gorm:"type:varchar(255)" json:"user_agent"`
|
||||
IP string `gorm:"type:varchar(64)" json:"ip"`
|
||||
}
|
||||
Reference in New Issue
Block a user