185 lines
4.9 KiB
Go
185 lines
4.9 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/gorilla/sessions"
|
|
"github.com/mattnite/forgejo-tickets/internal/email"
|
|
"github.com/mattnite/forgejo-tickets/internal/models"
|
|
"golang.org/x/crypto/bcrypt"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
const (
|
|
maxLoginAttempts = 5
|
|
lockoutDuration = 15 * time.Minute
|
|
)
|
|
|
|
type loginAttempt struct {
|
|
mu sync.Mutex
|
|
count int
|
|
lastFail time.Time
|
|
}
|
|
|
|
type Service struct {
|
|
db *gorm.DB
|
|
store *PGStore
|
|
email *email.Client
|
|
loginAttempts sync.Map
|
|
}
|
|
|
|
func NewService(db *gorm.DB, store *PGStore, emailClient *email.Client) *Service {
|
|
return &Service{
|
|
db: db,
|
|
store: store,
|
|
email: emailClient,
|
|
}
|
|
}
|
|
|
|
func (s *Service) Register(ctx context.Context, emailAddr, password, name string) (*models.User, error) {
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("hash password: %w", err)
|
|
}
|
|
|
|
hashStr := string(hash)
|
|
user := models.User{
|
|
Email: emailAddr,
|
|
PasswordHash: &hashStr,
|
|
Name: name,
|
|
EmailVerified: false,
|
|
}
|
|
|
|
if err := s.db.WithContext(ctx).Create(&user).Error; err != nil {
|
|
return nil, fmt.Errorf("create user: %w", err)
|
|
}
|
|
|
|
return &user, nil
|
|
}
|
|
|
|
func (s *Service) Login(ctx context.Context, emailAddr, password string) (*models.User, error) {
|
|
// Check if the account is locked due to too many failed attempts
|
|
if val, ok := s.loginAttempts.Load(emailAddr); ok {
|
|
attempt := val.(*loginAttempt)
|
|
attempt.mu.Lock()
|
|
locked := attempt.count >= maxLoginAttempts && time.Since(attempt.lastFail) < lockoutDuration
|
|
expired := time.Since(attempt.lastFail) >= lockoutDuration
|
|
attempt.mu.Unlock()
|
|
if locked {
|
|
return nil, fmt.Errorf("account temporarily locked, try again later")
|
|
}
|
|
if expired {
|
|
s.loginAttempts.Delete(emailAddr)
|
|
}
|
|
}
|
|
|
|
var user models.User
|
|
if err := s.db.WithContext(ctx).Where("email = ?", emailAddr).First(&user).Error; err != nil {
|
|
s.recordFailedAttempt(emailAddr)
|
|
return nil, fmt.Errorf("invalid email or password")
|
|
}
|
|
|
|
if user.PasswordHash == nil {
|
|
return nil, fmt.Errorf("this account uses social login")
|
|
}
|
|
|
|
if err := bcrypt.CompareHashAndPassword([]byte(*user.PasswordHash), []byte(password)); err != nil {
|
|
s.recordFailedAttempt(emailAddr)
|
|
return nil, fmt.Errorf("invalid email or password")
|
|
}
|
|
|
|
if !user.EmailVerified {
|
|
return nil, fmt.Errorf("please verify your email before logging in")
|
|
}
|
|
|
|
if !user.Approved {
|
|
return nil, fmt.Errorf("your account is pending admin approval")
|
|
}
|
|
|
|
// Clear failed attempts on successful login
|
|
s.loginAttempts.Delete(emailAddr)
|
|
|
|
return &user, nil
|
|
}
|
|
|
|
func (s *Service) recordFailedAttempt(emailAddr string) {
|
|
val, _ := s.loginAttempts.LoadOrStore(emailAddr, &loginAttempt{})
|
|
attempt := val.(*loginAttempt)
|
|
attempt.mu.Lock()
|
|
attempt.count++
|
|
attempt.lastFail = time.Now()
|
|
attempt.mu.Unlock()
|
|
}
|
|
|
|
func (s *Service) CreateSession(r *http.Request, w http.ResponseWriter, userID uuid.UUID) error {
|
|
// Always destroy any existing session first to prevent session fixation.
|
|
s.DestroySession(r, w)
|
|
|
|
// Create a brand new session with a fresh ID (bypass cookie reuse).
|
|
session := sessions.NewSession(s.store, sessionCookieName)
|
|
session.Options = s.store.Options()
|
|
session.IsNew = true
|
|
session.Values["user_id"] = userID.String()
|
|
return s.store.Save(r, w, session)
|
|
}
|
|
|
|
func (s *Service) DestroySession(r *http.Request, w http.ResponseWriter) error {
|
|
session, err := s.store.Get(r, sessionCookieName)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
session.Options.MaxAge = -1
|
|
return s.store.Save(r, w, session)
|
|
}
|
|
|
|
func (s *Service) ChangePassword(ctx context.Context, userID uuid.UUID, currentPassword, newPassword string) error {
|
|
var user models.User
|
|
if err := s.db.WithContext(ctx).First(&user, "id = ?", userID).Error; err != nil {
|
|
return fmt.Errorf("user not found")
|
|
}
|
|
|
|
if user.PasswordHash == nil {
|
|
return fmt.Errorf("this account uses social login and has no password to change")
|
|
}
|
|
|
|
if err := bcrypt.CompareHashAndPassword([]byte(*user.PasswordHash), []byte(currentPassword)); err != nil {
|
|
return fmt.Errorf("current password is incorrect")
|
|
}
|
|
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return fmt.Errorf("hash password: %w", err)
|
|
}
|
|
|
|
hashStr := string(hash)
|
|
return s.db.WithContext(ctx).Model(&user).Update("password_hash", hashStr).Error
|
|
}
|
|
|
|
func (s *Service) CreateUserWithPassword(ctx context.Context, emailAddr, password, name string, verified bool, approved bool) (*models.User, error) {
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("hash password: %w", err)
|
|
}
|
|
|
|
hashStr := string(hash)
|
|
user := models.User{
|
|
Email: emailAddr,
|
|
PasswordHash: &hashStr,
|
|
Name: name,
|
|
EmailVerified: verified,
|
|
Approved: approved,
|
|
}
|
|
|
|
if err := s.db.WithContext(ctx).Create(&user).Error; err != nil {
|
|
return nil, fmt.Errorf("create user: %w", err)
|
|
}
|
|
|
|
return &user, nil
|
|
}
|