2026-05-22 20:26:11 -04:00
|
|
|
package auth
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"errors"
|
|
|
|
|
"fmt"
|
2026-07-27 22:58:18 -04:00
|
|
|
netmail "net/mail"
|
|
|
|
|
"strings"
|
2026-05-22 20:26:11 -04:00
|
|
|
|
|
|
|
|
"github.com/Silo-Server/silo-server/internal/models"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Sentinel errors for authentication operations.
|
|
|
|
|
var (
|
|
|
|
|
ErrInvalidCredentials = errors.New("invalid credentials")
|
|
|
|
|
ErrUserDisabled = errors.New("user account is disabled")
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Credentials holds the username and password for authentication.
|
|
|
|
|
type Credentials struct {
|
|
|
|
|
Username string
|
|
|
|
|
Password string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// AuthProvider is the interface for pluggable authentication backends.
|
|
|
|
|
// Implementations validate user credentials and session state.
|
|
|
|
|
type AuthProvider interface {
|
|
|
|
|
// Authenticate validates the given credentials and returns the
|
|
|
|
|
// authenticated user. Returns ErrInvalidCredentials if the username
|
|
|
|
|
// does not exist or the password is wrong. Returns ErrUserDisabled
|
|
|
|
|
// if the account is disabled.
|
|
|
|
|
Authenticate(ctx context.Context, credentials Credentials) (*models.User, error)
|
|
|
|
|
|
|
|
|
|
// ValidateSession checks whether the given session ID is still valid
|
|
|
|
|
// (exists, not revoked, not expired).
|
|
|
|
|
ValidateSession(ctx context.Context, sessionID string) (bool, error)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// LocalProvider authenticates users against the local PostgreSQL database
|
|
|
|
|
// using bcrypt password hashing.
|
|
|
|
|
type LocalProvider struct {
|
|
|
|
|
users *UserRepository
|
|
|
|
|
sessions *SessionRepository
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 22:58:18 -04:00
|
|
|
// looksLikeEmail reports whether the login identifier is a plausible bare
|
|
|
|
|
// email address, gating the email-column fallback in Authenticate.
|
|
|
|
|
func looksLikeEmail(identifier string) bool {
|
|
|
|
|
identifier = strings.TrimSpace(identifier)
|
|
|
|
|
if !strings.Contains(identifier, "@") {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
parsed, err := netmail.ParseAddress(identifier)
|
|
|
|
|
return err == nil && parsed.Address == identifier
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-22 20:26:11 -04:00
|
|
|
// NewLocalProvider creates a new LocalProvider backed by the given repositories.
|
|
|
|
|
func NewLocalProvider(users *UserRepository, sessions *SessionRepository) *LocalProvider {
|
|
|
|
|
return &LocalProvider{
|
|
|
|
|
users: users,
|
|
|
|
|
sessions: sessions,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Authenticate validates the username/password pair against the database.
|
|
|
|
|
// Returns ErrInvalidCredentials if the user is not found or the password
|
|
|
|
|
// does not match. Returns ErrUserDisabled if the user's account is disabled.
|
2026-07-27 22:58:18 -04:00
|
|
|
//
|
|
|
|
|
// The identifier may also be an email address: when the username lookup
|
|
|
|
|
// misses and the input parses as an email, the email column is tried.
|
|
|
|
|
// Invited accounts have username == email, but someone who signed up with a
|
|
|
|
|
// separate username should still be able to type the address they remember.
|
|
|
|
|
// Both columns are citext UNIQUE over the same identity space, so the
|
|
|
|
|
// fallback cannot resolve ambiguously.
|
2026-05-22 20:26:11 -04:00
|
|
|
func (p *LocalProvider) Authenticate(ctx context.Context, creds Credentials) (*models.User, error) {
|
|
|
|
|
user, err := p.users.GetByUsername(ctx, creds.Username)
|
2026-07-27 22:58:18 -04:00
|
|
|
if err != nil && IsNotFound(err) && looksLikeEmail(creds.Username) {
|
|
|
|
|
user, err = p.users.GetByEmail(ctx, creds.Username)
|
|
|
|
|
}
|
2026-05-22 20:26:11 -04:00
|
|
|
if err != nil {
|
|
|
|
|
if IsNotFound(err) {
|
|
|
|
|
return nil, ErrInvalidCredentials
|
|
|
|
|
}
|
|
|
|
|
return nil, fmt.Errorf("looking up user: %w", err)
|
|
|
|
|
}
|
|
|
|
|
if !user.LocalPasswordLoginEnabled {
|
|
|
|
|
return nil, ErrInvalidCredentials
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if !CheckPassword(user, creds.Password) {
|
|
|
|
|
return nil, ErrInvalidCredentials
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if !user.Enabled {
|
|
|
|
|
return nil, ErrUserDisabled
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return user, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ValidateSession checks whether the session identified by sessionID is
|
|
|
|
|
// currently valid (exists, not revoked, not expired).
|
|
|
|
|
func (p *LocalProvider) ValidateSession(ctx context.Context, sessionID string) (bool, error) {
|
|
|
|
|
return p.sessions.IsValid(ctx, sessionID)
|
|
|
|
|
}
|