// Package auth implements admin authentication: password hashing/verification, // login, and (single) first-admin self-registration. See oidc.go for the Pocket ID // SSO flow, which also creates the first admin automatically. package auth import ( "context" "errors" "fmt" "regexp" "strings" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "golang.org/x/crypto/bcrypt" "amnezia-share/internal/models" ) var usernameRe = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`) // BcryptCost is the cost factor used for admin password hashes. const BcryptCost = bcrypt.DefaultCost // HashPassword hashes password with bcrypt at BcryptCost. func HashPassword(password string) (string, error) { b, err := bcrypt.GenerateFromPassword([]byte(password), BcryptCost) if err != nil { return "", fmt.Errorf("хеширование пароля: %w", err) } return string(b), nil } // VerifyPassword reports whether password matches the bcrypt hash. func VerifyPassword(hash, password string) bool { if hash == "" { return false } return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil } // ValidateUsername enforces the admin username policy: 3-64 chars, latin letters, // digits, dot, dash, underscore only. func ValidateUsername(username string) error { if len(username) < 3 || len(username) > 64 { return errors.New("Логин: от 3 до 64 символов.") } if !usernameRe.MatchString(username) { return errors.New("Логин: только латиница, цифры, точка, дефис, подчёркивание.") } return nil } // ValidatePassword enforces the admin password policy: at least 10 characters. func ValidatePassword(password string) error { if len(password) < 10 { return errors.New("Пароль не короче 10 символов.") } return nil } // Repo is the PostgreSQL-backed repository for admin accounts (the "users" table). type Repo struct { Pool *pgxpool.Pool } // New builds a Repo bound to pool. func New(pool *pgxpool.Pool) *Repo { return &Repo{Pool: pool} } // AdminCount returns the number of registered admin accounts. func (r *Repo) AdminCount(ctx context.Context) (int, error) { var c int if err := r.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM users`).Scan(&c); err != nil { return 0, err } return c, nil } // GetAdminByID loads an admin by id, or (nil, nil) if not found. func (r *Repo) GetAdminByID(ctx context.Context, id int) (*models.User, error) { if id <= 0 { return nil, nil } var u models.User err := r.Pool.QueryRow(ctx, `SELECT id, username, password_hash, role, created_at FROM users WHERE id=$1 LIMIT 1`, id). Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.CreatedAt) if errors.Is(err, pgx.ErrNoRows) { return nil, nil } if err != nil { return nil, err } return &u, nil } // GetAdminByUsername loads an admin by exact username match, or (nil, nil) if not // found. func (r *Repo) GetAdminByUsername(ctx context.Context, username string) (*models.User, error) { var u models.User err := r.Pool.QueryRow(ctx, `SELECT id, username, password_hash, role, created_at FROM users WHERE username=$1 LIMIT 1`, username). Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.CreatedAt) if errors.Is(err, pgx.ErrNoRows) { return nil, nil } if err != nil { return nil, err } return &u, nil } // LoginAdmin verifies username/password and returns the matching admin user. func (r *Repo) LoginAdmin(ctx context.Context, username, password string) (*models.User, error) { username = strings.TrimSpace(username) u, err := r.GetAdminByUsername(ctx, username) if err != nil { return nil, errors.New("не удалось выполнить вход") } if u == nil || !VerifyPassword(u.PasswordHash, password) { return nil, errors.New("Неверный логин или пароль.") } return u, nil } // RegisterFirstAdmin creates the single administrator account. It fails if any // admin already exists (use the login form instead). func (r *Repo) RegisterFirstAdmin(ctx context.Context, username, password string) (*models.User, error) { count, err := r.AdminCount(ctx) if err != nil { return nil, errors.New("не удалось проверить администраторов") } if count > 0 { return nil, errors.New("Администратор уже зарегистрирован. Войдите через форму входа.") } username = strings.TrimSpace(username) if err := ValidateUsername(username); err != nil { return nil, err } if err := ValidatePassword(password); err != nil { return nil, err } hash, err := HashPassword(password) if err != nil { return nil, errors.New("Не удалось сформировать хеш пароля.") } var id int err = r.Pool.QueryRow(ctx, `INSERT INTO users (username, password_hash, role) VALUES ($1,$2,'admin') RETURNING id`, username, hash).Scan(&id) if err != nil { return nil, errors.New("Ошибка БД: возможно, логин уже занят.") } return r.GetAdminByID(ctx, id) } // registerAdminWithHash inserts a new admin with a pre-computed password hash. // Used by the OIDC auto-provisioning flow (oidc.go) where the local password is // random and never used to sign in directly. func (r *Repo) registerAdminWithHash(ctx context.Context, username, passwordHash string) (*models.User, error) { var id int err := r.Pool.QueryRow(ctx, `INSERT INTO users (username, password_hash, role) VALUES ($1,$2,'admin') RETURNING id`, username, passwordHash).Scan(&id) if err != nil { return nil, fmt.Errorf("создание администратора: %w", err) } return r.GetAdminByID(ctx, id) }