initial commit

This commit is contained in:
m.zare
2026-04-10 18:25:21 +03:30
commit 77ca6c34a3
263 changed files with 34470 additions and 0 deletions

View File

@@ -0,0 +1,49 @@
package auth
import (
"context"
"github.com/google/uuid"
"base/internal/domain/auth"
"base/internal/dto"
)
func (s *service) GetUserInfo(ctx context.Context, userID uuid.UUID) (*dto.UserInfoResponse, error) {
user, err := s.userRepo.FindByID(ctx, userID)
if err != nil {
return nil, ErrUserNotFound
}
var profileID *uuid.UUID
prof, err := s.profileRepo.FindByUserID(ctx, userID)
if err == nil && prof != nil {
profileID = &prof.ID
}
return &dto.UserInfoResponse{
ID: user.ID,
Email: user.Email,
FirstName: user.FirstName,
LastName: user.LastName,
PhoneNumber: user.PhoneNumber,
EmailVerified: user.EmailVerified,
Status: userStatusToString(user.Status),
ProfileID: profileID,
}, nil
}
func userStatusToString(s auth.UserStatus) string {
switch s {
case auth.UserStatusActive:
return "active"
case auth.UserStatusInactive:
return "inactive"
case auth.UserStatusPending:
return "pending"
case auth.UserStatusDeleted:
return "deleted"
default:
return "unknown"
}
}

View File

@@ -0,0 +1,86 @@
package auth
import (
"base/pkg/jwt"
"context"
"fmt"
"time"
"github.com/google/uuid"
"base/internal/domain/auth"
"base/internal/dto"
)
func (s *service) GetOAuthRedirectURL(ctx context.Context, request dto.OAuthRedirectURLRequest) (string, error) {
provider := s.oauthService.Client(request.Provider)
state := uuid.New().String()
redirectURL := provider.GetConsentAuthUrl(ctx, state)
return redirectURL, nil
}
func (s *service) OAuthCallback(ctx context.Context, request dto.OAuthCallbackRequest) (*dto.OAuthCallbackResponse, error) {
oauthProvider := s.oauthService.Client(request.Provider)
token, err := oauthProvider.ExchangeCodeWithToken(ctx, request.Code)
if err != nil {
return nil, fmt.Errorf("failed to exchange code for token: %w", err)
}
userInfo, err := oauthProvider.GetUserInfo(ctx, token.AccessToken, token.RefreshToken)
if err != nil {
return nil, fmt.Errorf("failed to get user info: %w", err)
}
now := time.Now()
accessExpiry := now.Add(time.Duration(token.ExpiresIn) * time.Second)
refreshExpiry := now.Add(7 * 24 * time.Hour)
user := &auth.User{
ID: uuid.New(), // Will be set by repository if user exists
Email: userInfo.Email(),
FirstName: userInfo.FirstName(),
LastName: userInfo.LastName(),
Status: auth.UserStatusActive,
EmailVerified: true, // OAuth providers verify email
CreatedAt: now,
UpdatedAt: now,
}
account := &auth.Account{
ID: uuid.New(),
Provider: request.Provider,
AccessToken: &token.AccessToken,
RefreshToken: &token.RefreshToken,
AccessTokenExpiry: &accessExpiry,
RefreshTokenExpiry: &refreshExpiry,
CreatedAt: now,
UpdatedAt: now,
}
isNewUser, err := s.userRepo.UpsertWithAccount(ctx, userInfo.Email(), user, account)
if err != nil {
s.logger.Error().Err(err).Msg("failed to upsert user and account")
return nil, fmt.Errorf("failed to upsert user and account: %w", err)
}
tokens, genErr := s.jwtService.GenerateAccessRefreshTokenPair(ctx, &jwt.TokenData{Sub: user.ID.String()})
if genErr != nil {
return nil, fmt.Errorf("failed to generate tokens: %w", genErr)
}
s.logger.Info().
Str("user_id", user.ID.String()).
Str("email", user.Email).
Bool("is_new_user", isNewUser).
Str("provider", request.Provider.String()).
Msg("OAuth callback completed successfully")
return &dto.OAuthCallbackResponse{
AccessToken: tokens.AccessToken,
RefreshToken: tokens.RefreshToken,
IsNewUser: isNewUser,
}, nil
}

View File

@@ -0,0 +1,210 @@
package auth
import (
"context"
"fmt"
"time"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
"base/internal/domain/auth"
"base/internal/dto"
"base/internal/pkg/oauth"
"base/pkg/jwt"
)
func (s *service) RegisterWithCredentials(ctx context.Context, request dto.RegisterRequest) (*dto.TokenResponse, error) {
// Check if user already exists
existingUser, err := s.userRepo.FindByEmail(ctx, request.Email)
if err == nil && existingUser != nil {
return nil, ErrUserAlreadyExists
}
// Hash password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(request.Password), bcrypt.DefaultCost)
if err != nil {
s.logger.Error().Err(err).Msg("failed to hash password")
return nil, fmt.Errorf("failed to hash password: %w", err)
}
hashedPasswordStr := string(hashedPassword)
id, genErr := uuid.NewV7()
if genErr != nil {
return nil, genErr
}
// Create user and account within a transaction
// If any operation fails, all changes are rolled back
user := &auth.User{
ID: id,
Email: request.Email,
FirstName: request.FirstName,
LastName: request.LastName,
PhoneNumber: request.PhoneNumber,
Status: auth.UserStatusPending,
EmailVerified: false,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
account := &auth.Account{
ID: uuid.New(),
UserID: user.ID,
Provider: oauth.Credentials,
Password: &hashedPasswordStr,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err = s.userRepo.CreateWithAccount(ctx, user, account); err != nil {
s.logger.Error().Err(err).Msg("failed to create user and account")
return nil, fmt.Errorf("failed to create user and account: %w", err)
}
// Generate tokens
tokens, genTokenErr := s.jwtService.GenerateAccessRefreshTokenPair(ctx, &jwt.TokenData{Sub: user.ID.String()})
if genTokenErr != nil {
return nil, fmt.Errorf("failed to generate tokens: %w", genTokenErr)
}
// Update account with tokens
account.AccessToken = &tokens.AccessToken
account.RefreshToken = &tokens.RefreshToken
now := time.Now()
accessExpiry := now.Add(24 * time.Hour)
refreshExpiry := now.Add(7 * 24 * time.Hour)
account.AccessTokenExpiry = &accessExpiry
account.RefreshTokenExpiry = &refreshExpiry
if err = s.accountRepo.Update(ctx, account); err != nil {
s.logger.Error().Err(err).Msg("failed to update account with tokens")
// Don't fail the registration, tokens are already generated
}
// Profile is created when user calls setup-profile
return &dto.TokenResponse{
AccessToken: tokens.AccessToken,
RefreshToken: tokens.RefreshToken,
}, nil
}
func (s *service) LoginWithCredentials(ctx context.Context, email, password string) (*dto.TokenResponse, error) {
// Find user by email with accounts
user, err := s.userRepo.FindByEmail(ctx, email, auth.WithAccounts())
if err != nil {
return nil, ErrInvalidCredentials
}
// Check user status
if user.Status == auth.UserStatusDeleted {
return nil, ErrInvalidCredentials
}
// Find credentials account
var credentialsAccount *auth.Account
for _, acc := range user.Accounts {
if acc.Provider == oauth.Credentials {
credentialsAccount = &acc
break
}
}
if credentialsAccount == nil || credentialsAccount.Password == nil {
return nil, ErrInvalidCredentials
}
// Verify password
if err = bcrypt.CompareHashAndPassword([]byte(*credentialsAccount.Password), []byte(password)); err != nil {
return nil, ErrInvalidCredentials
}
// Generate tokens
tokens, err := s.jwtService.GenerateAccessRefreshTokenPair(ctx, &jwt.TokenData{Sub: user.ID.String()})
if err != nil {
return nil, fmt.Errorf("failed to generate tokens: %w", err)
}
// Update account with tokens
credentialsAccount.AccessToken = &tokens.AccessToken
credentialsAccount.RefreshToken = &tokens.RefreshToken
now := time.Now()
accessExpiry := now.Add(24 * time.Hour)
refreshExpiry := now.Add(7 * 24 * time.Hour)
credentialsAccount.AccessTokenExpiry = &accessExpiry
credentialsAccount.RefreshTokenExpiry = &refreshExpiry
if err := s.accountRepo.Update(ctx, credentialsAccount); err != nil {
s.logger.Error().Err(err).Msg("failed to update account with tokens")
// Don't fail the login, tokens are already generated
}
return &dto.TokenResponse{
AccessToken: tokens.AccessToken,
RefreshToken: tokens.RefreshToken,
}, nil
}
func (s *service) RefreshToken(ctx context.Context, refreshToken string) (*dto.TokenResponse, error) {
claims, err := s.jwtService.VerifyToken(ctx, refreshToken)
if err != nil {
return nil, ErrInvalidRefreshToken
}
userID, err := uuid.Parse(claims.Sub)
if err != nil {
return nil, ErrInvalidRefreshToken
}
// Find user
user, err := s.userRepo.FindByID(ctx, userID)
if err != nil {
return nil, ErrUserNotFound
}
accounts, err := s.accountRepo.FindByUserID(ctx, userID)
if err != nil {
return nil, ErrAccountNotFound
}
var matchingAccount *auth.Account
for _, acc := range accounts {
if acc.RefreshToken != nil && *acc.RefreshToken == refreshToken {
// Check if refresh token is expired
if acc.RefreshTokenExpiry != nil && acc.RefreshTokenExpiry.Before(time.Now()) {
return nil, ErrInvalidRefreshToken
}
matchingAccount = acc
break
}
}
if matchingAccount == nil {
return nil, ErrInvalidRefreshToken
}
tokens, err := s.jwtService.GenerateAccessRefreshTokenPair(ctx, &jwt.TokenData{Sub: user.ID.String()})
if err != nil {
return nil, fmt.Errorf("failed to generate tokens: %w", err)
}
matchingAccount.AccessToken = &tokens.AccessToken
matchingAccount.RefreshToken = &tokens.RefreshToken
now := time.Now()
accessExpiry := now.Add(24 * time.Hour)
refreshExpiry := now.Add(7 * 24 * time.Hour)
matchingAccount.AccessTokenExpiry = &accessExpiry
matchingAccount.RefreshTokenExpiry = &refreshExpiry
if err = s.accountRepo.Update(ctx, matchingAccount); err != nil {
s.logger.Error().Err(err).Msg("failed to update account with tokens")
// Don't fail the refresh, tokens are already generated
}
return &dto.TokenResponse{
AccessToken: tokens.AccessToken,
RefreshToken: tokens.RefreshToken,
}, nil
}

View File

@@ -0,0 +1,131 @@
package auth
import (
"context"
"fmt"
"time"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
"base/internal/domain/auth"
"base/internal/dto"
"base/internal/pkg/oauth"
"base/pkg/email"
"base/pkg/hashids"
"base/pkg/jwt"
)
// SendResetPasswordEmail sends a password reset email
func (s *service) SendResetPasswordEmail(ctx context.Context, request dto.SendResetPasswordEmailRequest) error {
user, err := s.userRepo.FindByEmail(ctx, request.Email)
if err != nil {
// Don't reveal if user exists or not for security
return err
}
// Generate reset code
code := hashids.GenerateCode(int64(user.ID.Time()))
key := fmt.Sprintf("reset_password:%s", user.ID.String())
// Store code in cache (15 minutes TTL)
if storeErr := s.resetPasswordStore.Set(ctx, key, code, 15*time.Minute); storeErr != nil {
return fmt.Errorf("failed to store reset password code: %w", storeErr)
}
// Send email
emailData := map[string]interface{}{
"Code": code,
"Name": user.FirstName,
}
emailMsg := email.Request{
To: user.Email,
Subject: "Reset Your Password",
Template: email.TemplateData{
EmailTemplateName: email.TemplatePasswordReset,
Data: emailData,
},
}
if _, sendEmailErr := s.emailService.Send(ctx, emailMsg); sendEmailErr != nil {
return fmt.Errorf("failed to send reset password email: %w", sendEmailErr)
}
return nil
}
// ResetPassword resets a user's password with the provided code
func (s *service) ResetPassword(ctx context.Context, request dto.ResetPasswordRequest) (*dto.TokenResponse, error) {
user, err := s.userRepo.FindByEmail(ctx, request.Email, auth.WithAccounts())
if err != nil {
return nil, ErrUserNotFound
}
// Get code from cache
key := fmt.Sprintf("reset_password:%s", user.ID.String())
storedCode, found, getErr := s.resetPasswordStore.Get(ctx, key)
if getErr != nil || !found {
return nil, ErrInvalidVerificationCode
}
if storedCode != request.Code {
return nil, ErrInvalidVerificationCode
}
// Find credentials account
var credentialsAccount *auth.Account
for _, acc := range user.Accounts {
if acc.Provider == oauth.Credentials {
credentialsAccount = &acc
break
}
}
// Hash new password
hashedPassword, genHashPassErr := bcrypt.GenerateFromPassword([]byte(request.Password), bcrypt.DefaultCost)
if genHashPassErr != nil {
return nil, fmt.Errorf("failed to hash password: %w", genHashPassErr)
}
hashedPasswordStr := string(hashedPassword)
if credentialsAccount != nil {
// Update existing account
credentialsAccount.Password = &hashedPasswordStr
credentialsAccount.UpdatedAt = time.Now()
if err := s.accountRepo.Update(ctx, credentialsAccount); err != nil {
return nil, fmt.Errorf("failed to update account: %w", err)
}
} else {
// Create new credentials account
account := &auth.Account{
ID: uuid.New(),
UserID: user.ID,
Provider: oauth.Credentials,
Password: &hashedPasswordStr,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := s.accountRepo.Create(ctx, account); err != nil {
return nil, fmt.Errorf("failed to create account: %w", err)
}
}
// Delete reset code from cache
_ = s.resetPasswordStore.Delete(ctx, key)
// Generate tokens
tokens, err := s.jwtService.GenerateAccessRefreshTokenPair(ctx, &jwt.TokenData{Sub: user.ID.String()})
if err != nil {
return nil, fmt.Errorf("failed to generate tokens: %w", err)
}
return &dto.TokenResponse{
AccessToken: tokens.AccessToken,
RefreshToken: tokens.RefreshToken,
}, nil
}

View File

@@ -0,0 +1,87 @@
package auth
import (
"context"
"errors"
"github.com/google/uuid"
"github.com/rs/zerolog"
"go.uber.org/fx"
"base/config"
"base/internal/domain/auth"
"base/internal/domain/profile"
"base/internal/dto"
"base/internal/pkg/oauth"
"base/pkg/email"
"base/pkg/jwt"
"base/pkg/store"
)
var (
ErrUserNotFound = errors.New("user not found")
ErrInvalidCredentials = errors.New("invalid credentials")
ErrUserAlreadyExists = errors.New("user already exists")
ErrInvalidVerificationCode = errors.New("invalid verification code")
ErrEmailAlreadyVerified = errors.New("email already verified")
ErrInvalidRefreshToken = errors.New("invalid refresh token")
ErrAccountNotFound = errors.New("account not found")
ErrProfileNotFound = errors.New("profile not found")
ErrProfileAlreadyExists = errors.New("profile already exists")
ErrHandleAlreadyTaken = errors.New("handle already taken")
)
type Service interface {
RegisterWithCredentials(ctx context.Context, request dto.RegisterRequest) (*dto.TokenResponse, error)
LoginWithCredentials(ctx context.Context, email, password string) (*dto.TokenResponse, error)
RefreshToken(ctx context.Context, refreshToken string) (*dto.TokenResponse, error)
GetOAuthRedirectURL(ctx context.Context, request dto.OAuthRedirectURLRequest) (string, error)
OAuthCallback(ctx context.Context, request dto.OAuthCallbackRequest) (*dto.OAuthCallbackResponse, error)
SendVerificationEmail(ctx context.Context, request dto.SendVerificationEmailRequest) error
VerifyAccount(ctx context.Context, request dto.VerifyAccountRequest) error
SendResetPasswordEmail(ctx context.Context, request dto.SendResetPasswordEmailRequest) error
ResetPassword(ctx context.Context, request dto.ResetPasswordRequest) (*dto.TokenResponse, error)
SetupProfile(ctx context.Context, userID uuid.UUID, request dto.SetupProfileRequest) error
GetUserInfo(ctx context.Context, userID uuid.UUID) (*dto.UserInfoResponse, error)
}
type service struct {
logger zerolog.Logger
config *config.AppConfig
userRepo auth.UserRepository
accountRepo auth.AccountRepository
profileRepo profile.Repository
emailService email.Email
oauthService oauth.OAuth
verificationStore store.Store[string]
resetPasswordStore store.Store[string]
jwtService jwt.TokenService
}
type Param struct {
Logger zerolog.Logger
Config *config.AppConfig
UserRepo auth.UserRepository
AccountRepo auth.AccountRepository
ProfileRepo profile.Repository
EmailService email.Email
OAuthService oauth.OAuth
VerificationStore store.Store[string] `name:"verification_store"`
ResetPasswordStore store.Store[string] `name:"reset_password_store"`
fx.In
}
func New(param Param) Service {
return &service{
logger: param.Logger,
config: param.Config,
userRepo: param.UserRepo,
accountRepo: param.AccountRepo,
profileRepo: param.ProfileRepo,
emailService: param.EmailService,
oauthService: param.OAuthService,
verificationStore: param.VerificationStore,
resetPasswordStore: param.ResetPasswordStore,
jwtService: jwt.New(param.Config.JWT.Secret, param.Config.JWT.AccessTokenExpiration, param.Config.JWT.RefreshTokenExpiration),
}
}

View File

@@ -0,0 +1,76 @@
package auth
import (
"context"
"fmt"
"regexp"
"strings"
"time"
"github.com/google/uuid"
"base/internal/domain/profile"
"base/internal/dto"
)
var slugRe = regexp.MustCompile(`[^a-z0-9-]+`)
func (s *service) SetupProfile(ctx context.Context, userID uuid.UUID, req dto.SetupProfileRequest) error {
existingProfile, _ := s.profileRepo.FindByUserID(ctx, userID)
if existingProfile != nil {
return ErrProfileAlreadyExists
}
user, err := s.userRepo.FindByID(ctx, userID)
if err != nil || user == nil {
return ErrUserNotFound
}
handle := generateHandle(user.FirstName, user.LastName, userID)
if req.Handle != "" {
handle = req.Handle
}
other, _ := s.profileRepo.FindByHandle(ctx, handle)
if other != nil {
return ErrHandleAlreadyTaken
}
newProfile := &profile.Profile{
ID: uuid.New(),
UserID: &userID,
Handle: handle,
Hero: profile.Hero{
FirstName: user.FirstName,
LastName: user.LastName,
ShortDescription: req.ShortDescription,
},
Contact: profile.Contact{
Email: user.Email,
Phone: user.PhoneNumber,
},
PageSetting: profile.PageSetting{
VisibilityLevel: "public",
},
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
newProfile.Hero.Role = &profile.Role{ID: req.RoleID}
if req.RoleLevel != "" && newProfile.Hero.Role != nil {
newProfile.Hero.Role.Level = req.RoleLevel
} else if req.RoleLevel != "" {
newProfile.Hero.Role = &profile.Role{Level: req.RoleLevel}
}
return s.profileRepo.Create(ctx, newProfile)
}
func generateHandle(firstName, lastName string, userID uuid.UUID) string {
slug := slugRe.ReplaceAllString(strings.ToLower(strings.TrimSpace(firstName+"-"+lastName)), "-")
slug = strings.Trim(slug, "-")
if slug == "" {
slug = "user"
}
return fmt.Sprintf("%s-%s", slug, userID.String()[:8])
}

View File

@@ -0,0 +1,16 @@
package auth
import (
"crypto/rand"
"fmt"
"math/big"
)
func generateOTP() (string, error) {
newInt := big.NewInt(10000) // 0 .. 999999
n, err := rand.Int(rand.Reader, newInt)
if err != nil {
return "", err
}
return fmt.Sprintf("%04d", n.Int64()), err
}

View File

@@ -0,0 +1,62 @@
package auth
import (
"context"
"fmt"
"time"
"base/internal/domain/auth"
"base/internal/dto"
"base/pkg/email"
)
func (s *service) SendVerificationEmail(ctx context.Context, request dto.SendVerificationEmailRequest) error {
emailMsg := email.Request{
To: request.Email,
Subject: "Verify Your Email",
Template: email.TemplateData{EmailTemplateName: email.TemplateEmailVerification},
}
if _, err := s.emailService.Send(ctx, emailMsg); err != nil {
s.logger.Error().Err(err).Msg("failed to send verification email")
return fmt.Errorf("failed to send verification email: %w", err)
}
return nil
}
func (s *service) VerifyAccount(ctx context.Context, request dto.VerifyAccountRequest) error {
user, err := s.userRepo.FindByEmail(ctx, request.Email)
if err != nil {
return ErrUserNotFound
}
if user.EmailVerified {
return ErrEmailAlreadyVerified
}
// Get code from cache
key := fmt.Sprintf("verification:%s", request.Email)
storedCode, found, err := s.verificationStore.Get(ctx, key)
if err != nil || !found {
return ErrInvalidVerificationCode
}
if storedCode != request.Code {
return ErrInvalidVerificationCode
}
user.EmailVerified = true
user.Status = auth.UserStatusActive
user.UpdatedAt = time.Now()
if err := s.userRepo.Update(ctx, user); err != nil {
return fmt.Errorf("failed to update user: %w", err)
}
// Delete verification code from cache
_ = s.verificationStore.Delete(ctx, key)
return nil
}